summaryrefslogtreecommitdiffstats
path: root/mobile/android/services/src
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/services/src')
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/ReadingListConstants.java23
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/EditorBranch.java82
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/GlobalConstants.java90
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/PrefsBranch.java83
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/Logger.java232
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLevelCachingLogWriter.java132
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLogWriter.java46
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LevelFilteringLogWriter.java67
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LogWriter.java29
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/PrintLogWriter.java77
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/SimpleTagLogWriter.java21
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/StringLogWriter.java57
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/TagLogWriter.java55
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/ThreadLocalTagLogWriter.java25
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/common/telemetry/TelemetryWrapper.java56
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/db/CursorDumper.java99
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/db/Tab.java86
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20CreateDelegate.java52
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20LoginDelegate.java36
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java24
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java914
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClientException.java133
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountRemoteError.java33
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountUtils.java217
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/PasswordStretcher.java12
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/QuickPasswordStretcher.java35
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/SkewHandler.java111
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClient.java224
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClientException.java68
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthClient10.java129
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthRemoteError.java19
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/profile/FxAccountProfileClient10.java59
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/nativecode/NativeCrypto.java60
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceFragment.java326
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceManagerCompat.java226
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/ASNUtils.java82
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/BrowserIDKeyPair.java35
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/DSACryptoImplementation.java255
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/JSONWebTokenUtils.java245
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/MockMyIDTokenFactory.java128
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/RSACryptoImplementation.java182
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/SigningPrivateKey.java41
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/VerifyingPublicKey.java34
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/AbstractBrowserIDRemoteVerifierClient.java95
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient10.java62
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient20.java58
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierClient.java9
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierDelegate.java13
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierException.java41
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/AccountLoader.java227
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FirefoxAccounts.java222
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountConstants.java75
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDevice.java81
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java282
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java95
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/SyncStatusListener.java31
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/CustomColorPreference.java52
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountAbstractActivity.java80
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountConfirmAccountActivityWeb.java11
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountFinishMigratingActivityWeb.java11
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountGetStartedActivityWeb.java11
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusActivity.java228
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusFragment.java949
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountUpdateCredentialsActivityWeb.java11
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountWebFlowActivity.java91
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/PicassoPreferenceIconTarget.java63
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AccountPickler.java362
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java929
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxADefaultLoginStateMachineDelegate.java84
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticator.java385
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticatorService.java55
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginDelegate.java26
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginException.java33
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/BaseRequestDelegate.java49
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Cohabiting.java50
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Doghouse.java25
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Engaged.java91
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginStateMachine.java84
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginTransition.java68
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Married.java117
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/MigratedFromSync11.java28
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Separated.java25
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/State.java72
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/StateFactory.java206
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/TokensAndKeysState.java45
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java154
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountUpgradeReceiver.java133
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountNotificationManager.java114
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountProfileService.java107
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSchedulePolicy.java178
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java568
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncDelegate.java110
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncService.java28
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncStatusHelper.java113
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/SchedulePolicy.java43
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/push/RegisterUserAgentResponse.java19
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/push/SubscribeChannelResponse.java19
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java410
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java81
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/AlreadySyncingException.java22
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/BackoffHandler.java34
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/BadRequiredFieldJSONException.java5
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/CollectionKeys.java199
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java261
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandRunner.java22
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/CredentialException.java56
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/CryptoRecord.java255
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/DelayedWorkTracker.java69
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/EngineSettings.java31
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/ExtendedJSONObject.java426
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java1167
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/HTTPFailureException.java47
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCollections.java103
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoConfiguration.java93
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCounts.java67
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/JSONRecordFetcher.java145
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/KeyBundleProvider.java11
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobal.java372
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalException.java45
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalMissingEnginesException.java9
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalNotSetException.java9
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/NoCollectionKeysSetException.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/NodeAuthenticationException.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonArrayJSONException.java17
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonObjectJSONException.java17
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/NullClusterURLException.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/PersistedMetaGlobal.java86
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/PrefsBackoffHandler.java59
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/README.txt1
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11PreviousPostFailedException.java12
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11RecordPostFailedException.java12
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java121
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/Sync11Configuration.java84
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfiguration.java480
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfigurationException.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConstants.java20
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncException.java34
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/SynchronizerConfiguration.java68
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/ThreadPool.java15
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnexpectedJSONException.java25
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnknownSynchronizerConfigurationVersionException.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/Utils.java575
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoException.java19
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoInfo.java232
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HKDF.java128
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HMACVerificationException.java12
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/KeyBundle.java135
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/MissingCryptoInputException.java9
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/NoKeyBundleException.java9
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PBKDF2.java78
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PersistedCrypto5Keys.java103
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/ClientsDataDelegate.java28
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/FreshStartDelegate.java10
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/GlobalSessionCallback.java49
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/JSONRecordFetchDelegate.java19
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/KeyUploadDelegate.java21
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/MetaGlobalDelegate.java15
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/WipeServerDelegate.java10
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepository.java76
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepositorySession.java172
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepository.java22
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepositorySession.java185
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AbstractBearerTokenAuthHeaderProvider.java34
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AuthHeaderProvider.java30
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.java565
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResourceDelegate.java44
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BasicAuthHeaderProvider.java51
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BearerAuthHeaderProvider.java22
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BrowserIDAuthHeaderProvider.java23
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ConnectionMonitorThread.java44
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/GzipNonChunkedCompressingEntity.java92
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HMACAuthHeaderProvider.java257
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HandleProgressException.java15
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HawkAuthHeaderProvider.java403
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HttpResponseObserver.java20
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/MozResponse.java225
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/Resource.java20
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ResourceDelegate.java55
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SRPConstants.java174
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncResponse.java157
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequest.java145
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequestDelegate.java9
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRecordRequest.java95
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequest.java204
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestDelegate.java38
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestIncrementalDelegate.java9
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageResponse.java85
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/TLSSocketFactory.java62
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBOCollectionRequestDelegate.java35
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBORequestDelegate.java14
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarkNeedsReparentingException.java17
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarksRepository.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ConstrainedServer11Repository.java51
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/FetchFailedException.java11
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HashSetStoreTracker.java61
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HistoryRepository.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/IdentityRecordFactory.java15
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InactiveSessionException.java17
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidBookmarkTypeException.java17
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidRequestException.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidSessionTransitionException.java17
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/MultipleRecordsForGuidException.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoContentProviderException.java25
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoGuidForIdException.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoStoreDelegateException.java11
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NullCursorException.java17
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ParentNotFoundException.java17
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ProfileDatabaseException.java17
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFactory.java13
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFilter.java11
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Repository.java18
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySession.java384
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySessionBundle.java55
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11Repository.java144
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11RepositorySession.java104
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreFailedException.java11
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTracker.java82
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTrackingRepositorySession.java102
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksDataAccessor.java326
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepository.java25
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepositorySession.java1107
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java188
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepository.java25
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java208
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepository.java74
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositoryDataAccessor.java232
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositorySession.java792
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksDeletionManager.java239
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksInsertionManager.java298
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpers.java154
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/CachedSQLiteOpenHelper.java62
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabase.java252
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabaseAccessor.java178
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FennecTabsRepository.java383
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FormHistoryRepositorySession.java723
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/PasswordsRepositorySession.java725
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/RepoUtils.java290
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/VisitsHelper.java130
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferrableRepositorySessionCreationDelegate.java41
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionBeginDelegate.java46
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFetchRecordsDelegate.java56
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFinishDelegate.java51
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionStoreDelegate.java57
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionBeginDelegate.java23
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCleanDelegate.java12
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCreationDelegate.java15
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFetchRecordsDelegate.java27
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFinishDelegate.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionGuidsSinceDelegate.java10
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionStoreDelegate.java23
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionWipeDelegate.java13
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecord.java488
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecordFactory.java25
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecord.java231
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecordFactory.java17
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/FormHistoryRecord.java139
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecord.java217
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecordFactory.java25
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecord.java205
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecordFactory.java19
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/Record.java308
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/RecordParseException.java14
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecord.java153
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecordFactory.java17
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/VersionConstants.java14
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloader.java310
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegate.java91
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchMeta.java165
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploader.java344
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BufferSizeTracker.java103
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/MayUploadProvider.java9
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/Payload.java66
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/PayloadUploadDelegate.java185
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/RecordUploadRunnable.java176
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/Constants.java29
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/InvalidSyncKeyException.java9
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/ActivityUtils.java34
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/WebURLFinder.java161
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractNonRepositorySyncStage.java26
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractSessionManagingSyncStage.java43
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserBookmarksServerSyncStage.java80
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserHistoryServerSyncStage.java74
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CheckPreconditionsStage.java13
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CompletedStage.java16
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/EnsureCrypto5KeysStage.java192
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FennecTabsServerSyncStage.java40
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoCollectionsStage.java44
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoConfigurationStage.java59
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchMetaGlobalStage.java79
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FormHistoryServerSyncStage.java76
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/GlobalSyncStage.java93
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/NoSuchStageException.java13
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/PasswordsServerSyncStage.java38
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SafeConstrainedServer11Repository.java110
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/ServerSyncStage.java627
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java691
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/UploadMetaGlobalStage.java18
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ConcurrentRecordConsumer.java122
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordConsumer.java26
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannel.java292
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannelDelegate.java13
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsConsumerDelegate.java23
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SerialRecordConsumer.java131
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizer.java18
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizerSession.java78
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SessionNotBegunException.java19
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/Synchronizer.java105
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerDelegate.java10
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSession.java425
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSessionDelegate.java13
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnbundleError.java19
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnexpectedSessionException.java26
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryContract.java56
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClient.java330
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClientDelegate.java19
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerException.java89
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerToken.java19
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/util/PRNGFixes.java339
-rw-r--r--mobile/android/services/src/main/res/drawable-hdpi/fxaccount_sync_error.pngbin0 -> 543 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-hdpi/sync_avatar_default.pngbin0 -> 5146 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-hdpi/sync_desktop.pngbin0 -> 196 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-hdpi/sync_desktop_inactive.pngbin0 -> 211 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-hdpi/sync_mobile.pngbin0 -> 163 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-hdpi/sync_mobile_inactive.pngbin0 -> 165 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-hdpi/sync_promo.pngbin0 -> 994 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xhdpi/fxaccount_sync_error.pngbin0 -> 716 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop.pngbin0 -> 229 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop_inactive.pngbin0 -> 244 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile.pngbin0 -> 210 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile_inactive.pngbin0 -> 215 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xhdpi/sync_promo.pngbin0 -> 1236 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xxhdpi/fxaccount_sync_error.pngbin0 -> 1070 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xxhdpi/sync_avatar_default.pngbin0 -> 11124 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop.pngbin0 -> 339 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop_inactive.pngbin0 -> 363 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile.pngbin0 -> 246 bytes
-rw-r--r--mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile_inactive.pngbin0 -> 249 bytes
-rw-r--r--mobile/android/services/src/main/res/layout/fxaccount_preference_list_fragment.xml40
-rw-r--r--mobile/android/services/src/main/res/layout/fxaccount_status_error_preference.xml66
-rw-r--r--mobile/android/services/src/main/res/layout/homescreen_prompt.xml92
-rw-r--r--mobile/android/services/src/main/res/layout/simple_helper_ui.xml61
-rw-r--r--mobile/android/services/src/main/res/menu/fxaccount_status_menu.xml8
-rw-r--r--mobile/android/services/src/main/res/values-v11/fxaccount_styles.xml21
-rw-r--r--mobile/android/services/src/main/res/values/fxaccount_colors.xml9
-rw-r--r--mobile/android/services/src/main/res/values/fxaccount_dimens.xml18
-rw-r--r--mobile/android/services/src/main/res/values/fxaccount_styles.xml27
-rw-r--r--mobile/android/services/src/main/res/xml/fxaccount_authenticator.xml11
-rw-r--r--mobile/android/services/src/main/res/xml/fxaccount_options.xml18
-rw-r--r--mobile/android/services/src/main/res/xml/fxaccount_status_prefscreen.xml142
-rw-r--r--mobile/android/services/src/main/res/xml/fxaccount_syncadapter.xml12
350 files changed, 38643 insertions, 0 deletions
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/ReadingListConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/ReadingListConstants.java
new file mode 100644
index 000000000..df603a58e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/ReadingListConstants.java
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background;
+
+import org.mozilla.gecko.AppConstants;
+
+/**
+ * This is in 'background' not 'reading' so that it's still usable even when the
+ * Reading List feature is build-time disabled.
+ */
+public class ReadingListConstants {
+ public static final String GLOBAL_LOG_TAG = "FxReadingList";
+ public static final String USER_AGENT = "Firefox-Android-FxReader/" + AppConstants.MOZ_APP_VERSION + " (" + AppConstants.MOZ_APP_UA_NAME + ")";
+ public static final String DEFAULT_DEV_ENDPOINT = "https://readinglist.dev.mozaws.net/v1/";
+ public static final String DEFAULT_PROD_ENDPOINT = "https://readinglist.services.mozilla.com/v1/";
+
+ public static final String OAUTH_SCOPE_READINGLIST = "readinglist";
+ public static final String AUTH_TOKEN_TYPE = "oauth::" + OAUTH_SCOPE_READINGLIST;
+
+ public static boolean DEBUG = false;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/EditorBranch.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/EditorBranch.java
new file mode 100644
index 000000000..1ead09afa
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/EditorBranch.java
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common;
+
+import java.util.Set;
+
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+
+public class EditorBranch implements Editor {
+
+ private final String prefix;
+ private Editor editor;
+
+ public EditorBranch(final SharedPreferences prefs, final String prefix) {
+ if (!prefix.endsWith(".")) {
+ throw new IllegalArgumentException("No trailing period in prefix.");
+ }
+ this.prefix = prefix;
+ this.editor = prefs.edit();
+ }
+
+ @Override
+ public void apply() {
+ this.editor.apply();
+ }
+
+ @Override
+ public Editor clear() {
+ this.editor = this.editor.clear();
+ return this;
+ }
+
+ @Override
+ public boolean commit() {
+ return this.editor.commit();
+ }
+
+ @Override
+ public Editor putBoolean(String key, boolean value) {
+ this.editor = this.editor.putBoolean(prefix + key, value);
+ return this;
+ }
+
+ @Override
+ public Editor putFloat(String key, float value) {
+ this.editor = this.editor.putFloat(prefix + key, value);
+ return this;
+ }
+
+ @Override
+ public Editor putInt(String key, int value) {
+ this.editor = this.editor.putInt(prefix + key, value);
+ return this;
+ }
+
+ @Override
+ public Editor putLong(String key, long value) {
+ this.editor = this.editor.putLong(prefix + key, value);
+ return this;
+ }
+
+ @Override
+ public Editor putString(String key, String value) {
+ this.editor = this.editor.putString(prefix + key, value);
+ return this;
+ }
+
+ // Not marking as Override, because Android <= 10 doesn't have
+ // putStringSet. Neither can we implement it.
+ public Editor putStringSet(String key, Set<String> value) {
+ throw new RuntimeException("putStringSet not available.");
+ }
+
+ @Override
+ public Editor remove(String key) {
+ this.editor = this.editor.remove(prefix + key);
+ return this;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/GlobalConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/GlobalConstants.java
new file mode 100644
index 000000000..d661e62dc
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/GlobalConstants.java
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.AppConstants.Versions;
+
+/**
+ * Constant values common to all Android services.
+ */
+public class GlobalConstants {
+ public static final String BROWSER_INTENT_PACKAGE = AppConstants.ANDROID_PACKAGE_NAME;
+ public static final String BROWSER_INTENT_CLASS = AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS;
+
+ public static final int SHARED_PREFERENCES_MODE = 0;
+
+ // Common time values.
+ public static final long MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+ public static final long MILLISECONDS_PER_SIX_MONTHS = 180 * MILLISECONDS_PER_DAY;
+
+ // Acceptable cipher suites.
+ /**
+ * We support only a very limited range of strong cipher suites and protocols:
+ * no SSLv3 or TLSv1.0 (if we can), no DHE ciphers that might be vulnerable to Logjam
+ * (https://weakdh.org/), no RC4.
+ *
+ * Backstory: Bug 717691 (we no longer support Android 2.2, so the name
+ * workaround is unnecessary), Bug 1081953, Bug 1061273, Bug 1166839.
+ *
+ * See <http://developer.android.com/reference/javax/net/ssl/SSLSocket.html> for
+ * supported Android versions for each set of protocols and cipher suites.
+ *
+ * Note that currently we need to support connections to Sync 1.1 on Mozilla-hosted infra,
+ * as well as connections to FxA and Sync 1.5 on AWS.
+ *
+ * ELB cipher suites:
+ * <http://docs.aws.amazon.com/ElasticLoadBalancing/latest/DeveloperGuide/elb-security-policy-table.html>
+ */
+ public static final String[] DEFAULT_CIPHER_SUITES;
+ public static final String[] DEFAULT_PROTOCOLS;
+
+ static {
+ // Prioritize 128 over 256 as a tradeoff between device CPU/battery and the minor
+ // increase in strength.
+ if (Versions.feature20Plus) {
+ DEFAULT_CIPHER_SUITES = new String[]
+ {
+ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", // 20+
+ "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", // 20+
+ "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", // 20+
+ "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", // 11+
+ "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", // 20+
+ "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", // 20+
+ "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", // 11+
+
+ // For Sync 1.1.
+ "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", // 9+
+ "TLS_RSA_WITH_AES_128_CBC_SHA", // 9+
+ };
+ } else {
+ DEFAULT_CIPHER_SUITES = new String[]
+ {
+ "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", // 11+
+ "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", // 11+
+ "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", // 11+
+
+ // For Sync 1.1.
+ "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", // 9+
+ "TLS_RSA_WITH_AES_128_CBC_SHA", // 9+
+ };
+ }
+
+ if (Versions.feature16Plus) {
+ DEFAULT_PROTOCOLS = new String[]
+ {
+ "TLSv1.2",
+ "TLSv1.1",
+ "TLSv1", // We would like to remove this, and will do so when we can.
+ };
+ } else {
+ // Fall back to TLSv1 if there's nothing better.
+ DEFAULT_PROTOCOLS = new String[]
+ {
+ "TLSv1",
+ };
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/PrefsBranch.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/PrefsBranch.java
new file mode 100644
index 000000000..78d5f61a1
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/PrefsBranch.java
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common;
+
+import java.util.Map;
+import java.util.Set;
+
+import android.content.SharedPreferences;
+
+/**
+ * A wrapper around a portion of the SharedPreferences space.
+ */
+public class PrefsBranch implements SharedPreferences {
+ private final SharedPreferences prefs;
+ private final String prefix; // Including trailing period.
+
+ public PrefsBranch(SharedPreferences prefs, String prefix) {
+ if (!prefix.endsWith(".")) {
+ throw new IllegalArgumentException("No trailing period in prefix.");
+ }
+ this.prefs = prefs;
+ this.prefix = prefix;
+ }
+
+ @Override
+ public boolean contains(String key) {
+ return prefs.contains(prefix + key);
+ }
+
+ @Override
+ public Editor edit() {
+ return new EditorBranch(prefs, prefix);
+ }
+
+ @Override
+ public Map<String, ?> getAll() {
+ // Not implemented. TODO
+ return null;
+ }
+
+ @Override
+ public boolean getBoolean(String key, boolean defValue) {
+ return prefs.getBoolean(prefix + key, defValue);
+ }
+
+ @Override
+ public float getFloat(String key, float defValue) {
+ return prefs.getFloat(prefix + key, defValue);
+ }
+
+ @Override
+ public int getInt(String key, int defValue) {
+ return prefs.getInt(prefix + key, defValue);
+ }
+
+ @Override
+ public long getLong(String key, long defValue) {
+ return prefs.getLong(prefix + key, defValue);
+ }
+
+ @Override
+ public String getString(String key, String defValue) {
+ return prefs.getString(prefix + key, defValue);
+ }
+
+ // Not marking as Override, because Android <= 10 doesn't have
+ // getStringSet. Neither can we implement it.
+ public Set<String> getStringSet(String key, Set<String> defValue) {
+ throw new RuntimeException("getStringSet not available.");
+ }
+
+ @Override
+ public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
+ prefs.registerOnSharedPreferenceChangeListener(listener);
+ }
+
+ @Override
+ public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
+ prefs.unregisterOnSharedPreferenceChangeListener(listener);
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/Logger.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/Logger.java
new file mode 100644
index 000000000..2575717eb
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/Logger.java
@@ -0,0 +1,232 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common.log;
+
+import java.io.PrintWriter;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.background.common.log.writers.AndroidLevelCachingLogWriter;
+import org.mozilla.gecko.background.common.log.writers.AndroidLogWriter;
+import org.mozilla.gecko.background.common.log.writers.LogWriter;
+import org.mozilla.gecko.background.common.log.writers.PrintLogWriter;
+import org.mozilla.gecko.background.common.log.writers.SimpleTagLogWriter;
+import org.mozilla.gecko.background.common.log.writers.ThreadLocalTagLogWriter;
+
+import android.util.Log;
+
+/**
+ * Logging helper class. Serializes all log operations (by synchronizing).
+ */
+public class Logger {
+ public static final String LOGGER_TAG = "Logger";
+ public static final String DEFAULT_LOG_TAG = "GeckoLogger";
+
+ // For extra debugging.
+ public static boolean LOG_PERSONAL_INFORMATION = false;
+
+ /**
+ * Allow each thread to use its own global log tag. This allows
+ * independent services to log as different sources.
+ *
+ * When your thread sets up logging, it should do something like the following:
+ *
+ * Logger.setThreadLogTag("MyTag");
+ *
+ * The value is inheritable, so worker threads and such do not need to
+ * set the same log tag as their parent.
+ */
+ private static final InheritableThreadLocal<String> logTag = new InheritableThreadLocal<String>() {
+ @Override
+ protected String initialValue() {
+ return DEFAULT_LOG_TAG;
+ }
+ };
+
+ public static void setThreadLogTag(final String logTag) {
+ Logger.logTag.set(logTag);
+ }
+ public static String getThreadLogTag() {
+ return Logger.logTag.get();
+ }
+
+ /**
+ * Current set of writers to which we will log.
+ * <p>
+ * We want logging to be available while running tests, so we initialize
+ * this set statically.
+ */
+ protected final static Set<LogWriter> logWriters;
+ static {
+ final Set<LogWriter> defaultWriters = Logger.defaultLogWriters();
+ logWriters = new LinkedHashSet<LogWriter>(defaultWriters);
+ }
+
+ /**
+ * Default set of log writers to log to.
+ */
+ public final static Set<LogWriter> defaultLogWriters() {
+ final String processedPackage = GlobalConstants.BROWSER_INTENT_PACKAGE.replace("org.mozilla.", "");
+
+ final Set<LogWriter> defaultLogWriters = new LinkedHashSet<LogWriter>();
+
+ final LogWriter log = new AndroidLogWriter();
+ final LogWriter cache = new AndroidLevelCachingLogWriter(log);
+
+ final LogWriter single = new SimpleTagLogWriter(processedPackage, new ThreadLocalTagLogWriter(Logger.logTag, cache));
+
+ defaultLogWriters.add(single);
+ return defaultLogWriters;
+ }
+
+ public static synchronized void startLoggingTo(LogWriter logWriter) {
+ logWriters.add(logWriter);
+ }
+
+ public static synchronized void startLoggingToWriters(Set<LogWriter> writers) {
+ logWriters.addAll(writers);
+ }
+
+ public static synchronized void stopLoggingTo(LogWriter logWriter) {
+ try {
+ logWriter.close();
+ } catch (Exception e) {
+ Log.e(LOGGER_TAG, "Got exception closing and removing LogWriter " + logWriter + ".", e);
+ }
+ logWriters.remove(logWriter);
+ }
+
+ public static synchronized void stopLoggingToAll() {
+ for (LogWriter logWriter : logWriters) {
+ try {
+ logWriter.close();
+ } catch (Exception e) {
+ Log.e(LOGGER_TAG, "Got exception closing and removing LogWriter " + logWriter + ".", e);
+ }
+ }
+ logWriters.clear();
+ }
+
+ /**
+ * Write to only the default log writers.
+ */
+ public static synchronized void resetLogging() {
+ stopLoggingToAll();
+ logWriters.addAll(Logger.defaultLogWriters());
+ }
+
+ /**
+ * Start writing log output to stdout.
+ * <p>
+ * Use <code>resetLogging</code> to stop logging to stdout.
+ */
+ public static synchronized void startLoggingToConsole() {
+ setThreadLogTag("Test");
+ startLoggingTo(new PrintLogWriter(new PrintWriter(System.out, true)));
+ }
+
+ // Synchronized version for other classes to use.
+ public static synchronized boolean shouldLogVerbose(String logTag) {
+ for (LogWriter logWriter : logWriters) {
+ if (logWriter.shouldLogVerbose(logTag)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static void error(String tag, String message) {
+ Logger.error(tag, message, null);
+ }
+
+ public static void warn(String tag, String message) {
+ Logger.warn(tag, message, null);
+ }
+
+ public static void info(String tag, String message) {
+ Logger.info(tag, message, null);
+ }
+
+ public static void debug(String tag, String message) {
+ Logger.debug(tag, message, null);
+ }
+
+ public static void trace(String tag, String message) {
+ Logger.trace(tag, message, null);
+ }
+
+ public static void pii(String tag, String message) {
+ if (LOG_PERSONAL_INFORMATION) {
+ Logger.debug(tag, "$$PII$$: " + message);
+ }
+ }
+
+ public static synchronized void error(String tag, String message, Throwable error) {
+ Iterator<LogWriter> it = logWriters.iterator();
+ while (it.hasNext()) {
+ LogWriter writer = it.next();
+ try {
+ writer.error(tag, message, error);
+ } catch (Exception e) {
+ Log.e(LOGGER_TAG, "Got exception logging; removing LogWriter " + writer + ".", e);
+ it.remove();
+ }
+ }
+ }
+
+ public static synchronized void warn(String tag, String message, Throwable error) {
+ Iterator<LogWriter> it = logWriters.iterator();
+ while (it.hasNext()) {
+ LogWriter writer = it.next();
+ try {
+ writer.warn(tag, message, error);
+ } catch (Exception e) {
+ Log.e(LOGGER_TAG, "Got exception logging; removing LogWriter " + writer + ".", e);
+ it.remove();
+ }
+ }
+ }
+
+ public static synchronized void info(String tag, String message, Throwable error) {
+ Iterator<LogWriter> it = logWriters.iterator();
+ while (it.hasNext()) {
+ LogWriter writer = it.next();
+ try {
+ writer.info(tag, message, error);
+ } catch (Exception e) {
+ Log.e(LOGGER_TAG, "Got exception logging; removing LogWriter " + writer + ".", e);
+ it.remove();
+ }
+ }
+ }
+
+ public static synchronized void debug(String tag, String message, Throwable error) {
+ Iterator<LogWriter> it = logWriters.iterator();
+ while (it.hasNext()) {
+ LogWriter writer = it.next();
+ try {
+ writer.debug(tag, message, error);
+ } catch (Exception e) {
+ Log.e(LOGGER_TAG, "Got exception logging; removing LogWriter " + writer + ".", e);
+ it.remove();
+ }
+ }
+ }
+
+ public static synchronized void trace(String tag, String message, Throwable error) {
+ Iterator<LogWriter> it = logWriters.iterator();
+ while (it.hasNext()) {
+ LogWriter writer = it.next();
+ try {
+ writer.trace(tag, message, error);
+ } catch (Exception e) {
+ Log.e(LOGGER_TAG, "Got exception logging; removing LogWriter " + writer + ".", e);
+ it.remove();
+ }
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLevelCachingLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLevelCachingLogWriter.java
new file mode 100644
index 000000000..ac4250a03
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLevelCachingLogWriter.java
@@ -0,0 +1,132 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common.log.writers;
+
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+import android.util.Log;
+
+/**
+ * Make a <code>LogWriter</code> only log when the Android log system says to.
+ */
+public class AndroidLevelCachingLogWriter extends LogWriter {
+ protected final LogWriter inner;
+
+ public AndroidLevelCachingLogWriter(LogWriter inner) {
+ this.inner = inner;
+ }
+
+ // I can't believe we have to implement this ourselves.
+ // These aren't synchronized (and neither are the setters) because
+ // the logging calls themselves are synchronized.
+ private Map<String, Boolean> isErrorLoggable = new IdentityHashMap<String, Boolean>();
+ private Map<String, Boolean> isWarnLoggable = new IdentityHashMap<String, Boolean>();
+ private Map<String, Boolean> isInfoLoggable = new IdentityHashMap<String, Boolean>();
+ private Map<String, Boolean> isDebugLoggable = new IdentityHashMap<String, Boolean>();
+ private Map<String, Boolean> isVerboseLoggable = new IdentityHashMap<String, Boolean>();
+
+ /**
+ * Empty the caches of log levels.
+ */
+ public void refreshLogLevels() {
+ isErrorLoggable = new IdentityHashMap<String, Boolean>();
+ isWarnLoggable = new IdentityHashMap<String, Boolean>();
+ isInfoLoggable = new IdentityHashMap<String, Boolean>();
+ isDebugLoggable = new IdentityHashMap<String, Boolean>();
+ isVerboseLoggable = new IdentityHashMap<String, Boolean>();
+ }
+
+ private boolean shouldLogError(String logTag) {
+ Boolean out = isErrorLoggable.get(logTag);
+ if (out != null) {
+ return out;
+ }
+ out = Log.isLoggable(logTag, Log.ERROR);
+ isErrorLoggable.put(logTag, out);
+ return out;
+ }
+
+ private boolean shouldLogWarn(String logTag) {
+ Boolean out = isWarnLoggable.get(logTag);
+ if (out != null) {
+ return out;
+ }
+ out = Log.isLoggable(logTag, Log.WARN);
+ isWarnLoggable.put(logTag, out);
+ return out;
+ }
+
+ private boolean shouldLogInfo(String logTag) {
+ Boolean out = isInfoLoggable.get(logTag);
+ if (out != null) {
+ return out;
+ }
+ out = Log.isLoggable(logTag, Log.INFO);
+ isInfoLoggable.put(logTag, out);
+ return out;
+ }
+
+ private boolean shouldLogDebug(String logTag) {
+ Boolean out = isDebugLoggable.get(logTag);
+ if (out != null) {
+ return out;
+ }
+ out = Log.isLoggable(logTag, Log.DEBUG);
+ isDebugLoggable.put(logTag, out);
+ return out;
+ }
+
+ @Override
+ public boolean shouldLogVerbose(String logTag) {
+ Boolean out = isVerboseLoggable.get(logTag);
+ if (out != null) {
+ return out;
+ }
+ out = Log.isLoggable(logTag, Log.VERBOSE);
+ isVerboseLoggable.put(logTag, out);
+ return out;
+ }
+
+ @Override
+ public void error(String tag, String message, Throwable error) {
+ if (shouldLogError(tag)) {
+ inner.error(tag, message, error);
+ }
+ }
+
+ @Override
+ public void warn(String tag, String message, Throwable error) {
+ if (shouldLogWarn(tag)) {
+ inner.warn(tag, message, error);
+ }
+ }
+
+ @Override
+ public void info(String tag, String message, Throwable error) {
+ if (shouldLogInfo(tag)) {
+ inner.info(tag, message, error);
+ }
+ }
+
+ @Override
+ public void debug(String tag, String message, Throwable error) {
+ if (shouldLogDebug(tag)) {
+ inner.debug(tag, message, error);
+ }
+ }
+
+ @Override
+ public void trace(String tag, String message, Throwable error) {
+ if (shouldLogVerbose(tag)) {
+ inner.trace(tag, message, error);
+ }
+ }
+
+ @Override
+ public void close() {
+ inner.close();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLogWriter.java
new file mode 100644
index 000000000..9d309844d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/AndroidLogWriter.java
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common.log.writers;
+
+import android.util.Log;
+
+/**
+ * Log to the Android log.
+ */
+public class AndroidLogWriter extends LogWriter {
+ @Override
+ public boolean shouldLogVerbose(String logTag) {
+ return true;
+ }
+
+ @Override
+ public void error(String tag, String message, Throwable error) {
+ Log.e(tag, message, error);
+ }
+
+ @Override
+ public void warn(String tag, String message, Throwable error) {
+ Log.w(tag, message, error);
+ }
+
+ @Override
+ public void info(String tag, String message, Throwable error) {
+ Log.i(tag, message, error);
+ }
+
+ @Override
+ public void debug(String tag, String message, Throwable error) {
+ Log.d(tag, message, error);
+ }
+
+ @Override
+ public void trace(String tag, String message, Throwable error) {
+ Log.v(tag, message, error);
+ }
+
+ @Override
+ public void close() {
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LevelFilteringLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LevelFilteringLogWriter.java
new file mode 100644
index 000000000..74c3608c4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LevelFilteringLogWriter.java
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common.log.writers;
+
+import android.util.Log;
+
+/**
+ * A LogWriter that logs only if the message is as important as the specified
+ * level. For example, if the specified level is <code>Log.WARN</code>, only
+ * <code>warn</code> and <code>error</code> will log.
+ */
+public class LevelFilteringLogWriter extends LogWriter {
+ protected final LogWriter inner;
+ protected final int logLevel;
+
+ public LevelFilteringLogWriter(int logLevel, LogWriter inner) {
+ this.inner = inner;
+ this.logLevel = logLevel;
+ }
+
+ @Override
+ public void close() {
+ inner.close();
+ }
+
+ @Override
+ public void error(String tag, String message, Throwable error) {
+ if (logLevel <= Log.ERROR) {
+ inner.error(tag, message, error);
+ }
+ }
+
+ @Override
+ public void warn(String tag, String message, Throwable error) {
+ if (logLevel <= Log.WARN) {
+ inner.warn(tag, message, error);
+ }
+ }
+
+ @Override
+ public void info(String tag, String message, Throwable error) {
+ if (logLevel <= Log.INFO) {
+ inner.info(tag, message, error);
+ }
+ }
+
+ @Override
+ public void debug(String tag, String message, Throwable error) {
+ if (logLevel <= Log.DEBUG) {
+ inner.debug(tag, message, error);
+ }
+ }
+
+ @Override
+ public void trace(String tag, String message, Throwable error) {
+ if (logLevel <= Log.VERBOSE) {
+ inner.trace(tag, message, error);
+ }
+ }
+
+ @Override
+ public boolean shouldLogVerbose(String tag) {
+ return logLevel <= Log.VERBOSE;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LogWriter.java
new file mode 100644
index 000000000..acfb09969
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/LogWriter.java
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common.log.writers;
+
+/**
+ * An abstract object that logs information in some way.
+ * <p>
+ * Intended to be composed with other log writers, for example a log
+ * writer could make all log entries have the same single log tag, or
+ * could ignore certain log levels, before delegating to an inner log
+ * writer.
+ */
+public abstract class LogWriter {
+ public abstract void error(String tag, String message, Throwable error);
+ public abstract void warn(String tag, String message, Throwable error);
+ public abstract void info(String tag, String message, Throwable error);
+ public abstract void debug(String tag, String message, Throwable error);
+ public abstract void trace(String tag, String message, Throwable error);
+
+ /**
+ * We expect <code>close</code> to be called only by static
+ * synchronized methods in class <code>Logger</code>.
+ */
+ public abstract void close();
+
+ public abstract boolean shouldLogVerbose(String tag);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/PrintLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/PrintLogWriter.java
new file mode 100644
index 000000000..6e1f63de3
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/PrintLogWriter.java
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common.log.writers;
+
+import java.io.PrintWriter;
+
+/**
+ * Log to a <code>PrintWriter</code>.
+ */
+public class PrintLogWriter extends LogWriter {
+ protected final PrintWriter pw;
+ protected boolean closed = false;
+
+ public static final String ERROR = " :: E :: ";
+ public static final String WARN = " :: W :: ";
+ public static final String INFO = " :: I :: ";
+ public static final String DEBUG = " :: D :: ";
+ public static final String VERBOSE = " :: V :: ";
+
+ public PrintLogWriter(PrintWriter pw) {
+ this.pw = pw;
+ }
+
+ protected void log(String tag, String message, Throwable error) {
+ if (closed) {
+ return;
+ }
+
+ pw.println(tag + message);
+ if (error != null) {
+ error.printStackTrace(pw);
+ }
+ }
+
+ @Override
+ public void error(String tag, String message, Throwable error) {
+ log(tag, ERROR + message, error);
+ }
+
+ @Override
+ public void warn(String tag, String message, Throwable error) {
+ log(tag, WARN + message, error);
+ }
+
+ @Override
+ public void info(String tag, String message, Throwable error) {
+ log(tag, INFO + message, error);
+ }
+
+ @Override
+ public void debug(String tag, String message, Throwable error) {
+ log(tag, DEBUG + message, error);
+ }
+
+ @Override
+ public void trace(String tag, String message, Throwable error) {
+ log(tag, VERBOSE + message, error);
+ }
+
+ @Override
+ public boolean shouldLogVerbose(String tag) {
+ return true;
+ }
+
+ @Override
+ public void close() {
+ if (closed) {
+ return;
+ }
+ if (pw != null) {
+ pw.close();
+ }
+ closed = true;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/SimpleTagLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/SimpleTagLogWriter.java
new file mode 100644
index 000000000..a17654371
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/SimpleTagLogWriter.java
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common.log.writers;
+
+/**
+ * Make a <code>LogWriter</code> only log with a single string tag.
+ */
+public class SimpleTagLogWriter extends TagLogWriter {
+ final String tag;
+ public SimpleTagLogWriter(String tag, LogWriter inner) {
+ super(inner);
+ this.tag = tag;
+ }
+
+ @Override
+ protected String getMainTag() {
+ return tag;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/StringLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/StringLogWriter.java
new file mode 100644
index 000000000..d6a9f5eb8
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/StringLogWriter.java
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common.log.writers;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+public class StringLogWriter extends LogWriter {
+ protected final StringWriter sw;
+ protected final PrintLogWriter inner;
+
+ public StringLogWriter() {
+ sw = new StringWriter();
+ inner = new PrintLogWriter(new PrintWriter(sw));
+ }
+
+ public String toString() {
+ return sw.toString();
+ }
+
+ @Override
+ public boolean shouldLogVerbose(String tag) {
+ return true;
+ }
+
+ @Override
+ public void error(String tag, String message, Throwable error) {
+ inner.error(tag, message, error);
+ }
+
+ @Override
+ public void warn(String tag, String message, Throwable error) {
+ inner.warn(tag, message, error);
+ }
+
+ @Override
+ public void info(String tag, String message, Throwable error) {
+ inner.info(tag, message, error);
+ }
+
+ @Override
+ public void debug(String tag, String message, Throwable error) {
+ inner.debug(tag, message, error);
+ }
+
+ @Override
+ public void trace(String tag, String message, Throwable error) {
+ inner.trace(tag, message, error);
+ }
+
+ @Override
+ public void close() {
+ inner.close();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/TagLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/TagLogWriter.java
new file mode 100644
index 000000000..fbcd94a91
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/TagLogWriter.java
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common.log.writers;
+
+/**
+ * A @link{LogWriter} that logs each message under a parent tag.
+ */
+public abstract class TagLogWriter extends LogWriter {
+
+ protected final LogWriter inner;
+
+ public TagLogWriter(final LogWriter inner) {
+ super();
+ this.inner = inner;
+ }
+
+ protected abstract String getMainTag();
+
+ @Override
+ public void error(String tag, String message, Throwable error) {
+ inner.error(this.getMainTag(), tag + " :: " + message, error);
+ }
+
+ @Override
+ public void warn(String tag, String message, Throwable error) {
+ inner.warn(this.getMainTag(), tag + " :: " + message, error);
+ }
+
+ @Override
+ public void info(String tag, String message, Throwable error) {
+ inner.info(this.getMainTag(), tag + " :: " + message, error);
+ }
+
+ @Override
+ public void debug(String tag, String message, Throwable error) {
+ inner.debug(this.getMainTag(), tag + " :: " + message, error);
+ }
+
+ @Override
+ public void trace(String tag, String message, Throwable error) {
+ inner.trace(this.getMainTag(), tag + " :: " + message, error);
+ }
+
+ @Override
+ public boolean shouldLogVerbose(String tag) {
+ return inner.shouldLogVerbose(this.getMainTag());
+ }
+
+ @Override
+ public void close() {
+ inner.close();
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/ThreadLocalTagLogWriter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/ThreadLocalTagLogWriter.java
new file mode 100644
index 000000000..0c83504a0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/log/writers/ThreadLocalTagLogWriter.java
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common.log.writers;
+
+/**
+ * Log with a single global tag… but that tag can be different for each thread.
+ *
+ * Takes a @link{ThreadLocal} as a constructor parameter.
+ */
+public class ThreadLocalTagLogWriter extends TagLogWriter {
+
+ private final ThreadLocal<String> tag;
+
+ public ThreadLocalTagLogWriter(ThreadLocal<String> tag, LogWriter inner) {
+ super(inner);
+ this.tag = tag;
+ }
+
+ @Override
+ protected String getMainTag() {
+ return this.tag.get();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/telemetry/TelemetryWrapper.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/telemetry/TelemetryWrapper.java
new file mode 100644
index 000000000..6639b817d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/common/telemetry/TelemetryWrapper.java
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.common.telemetry;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+/**
+ * Android Background Services are normally built into Fennec, but can also be
+ * built as a stand-alone APK for rapid local development. The current Telemetry
+ * implementation is coupled to Gecko, and Background Services should not
+ * interact with Gecko directly. To maintain this independence, Background
+ * Services lazily introspects the relevant Telemetry class from the enclosing
+ * package, warning but otherwise ignoring failures during introspection or
+ * invocation.
+ * <p>
+ * It is possible that Background Services will introspect and invoke the
+ * Telemetry implementation while Gecko is not running. In this case, the Fennec
+ * process itself buffers Telemetry events until such time as they can be
+ * flushed to disk and uploaded. <b>There is no guarantee that all Telemetry
+ * events will be uploaded!</b> Depending on the volume of data and the
+ * application lifecycle, Telemetry events may be dropped.
+ */
+public class TelemetryWrapper {
+ private static final String LOG_TAG = TelemetryWrapper.class.getSimpleName();
+
+ // Marking this volatile maintains thread safety cheaply.
+ private static volatile Method mAddToHistogram;
+
+ public static void addToHistogram(String key, int value) {
+ if (mAddToHistogram == null) {
+ try {
+ final Class<?> telemetry = Class.forName("org.mozilla.gecko.Telemetry");
+ mAddToHistogram = telemetry.getMethod("addToHistogram", String.class, int.class);
+ } catch (ClassNotFoundException e) {
+ Logger.warn(LOG_TAG, "org.mozilla.gecko.Telemetry class found!");
+ return;
+ } catch (NoSuchMethodException e) {
+ Logger.warn(LOG_TAG, "org.mozilla.gecko.Telemetry.addToHistogram(String, int) method not found!");
+ return;
+ }
+ }
+
+ if (mAddToHistogram != null) {
+ try {
+ mAddToHistogram.invoke(null, key, value);
+ } catch (IllegalArgumentException | InvocationTargetException | IllegalAccessException e) {
+ Logger.warn(LOG_TAG, "Got exception invoking telemetry!");
+ }
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/CursorDumper.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/CursorDumper.java
new file mode 100644
index 000000000..bce968b00
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/CursorDumper.java
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.db;
+
+import android.database.Cursor;
+
+/**
+ * A utility for dumping a cursor the debug log.
+ * <p>
+ * <b>For debugging only!</p>
+ */
+public class CursorDumper {
+ protected static String fixedWidth(int width, String s) {
+ if (s == null) {
+ return spaces(width);
+ }
+ int length = s.length();
+ if (width == length) {
+ return s;
+ }
+ if (width > length) {
+ return s + spaces(width - length);
+ }
+ return s.substring(0, width);
+ }
+
+ protected static String spaces(int i) {
+ return " ".substring(0, i);
+ }
+
+ protected static String dashes(int i) {
+ return "-------------------------------------".substring(0, i);
+ }
+
+ /**
+ * Dump a cursor to the debug log, ignoring any log level settings.
+ * <p>
+ * The position in the cursor is maintained. Caller is responsible for opening
+ * and closing cursor.
+ *
+ * @param cursor
+ * to dump.
+ */
+ public static void dumpCursor(Cursor cursor) {
+ dumpCursor(cursor, 18, "records");
+ }
+
+ /**
+ * Dump a cursor to the debug log, ignoring any log level settings.
+ * <p>
+ * The position in the cursor is maintained. Caller is responsible for opening
+ * and closing cursor.
+ *
+ * @param cursor
+ * to dump.
+ * @param columnWidth
+ * how many characters per cursor column.
+ * @param tags
+ * a descriptor, printed like "(10 tags)", in the header row.
+ */
+ protected static void dumpCursor(Cursor cursor, int columnWidth, String tags) {
+ int originalPosition = cursor.getPosition();
+ try {
+ String[] columnNames = cursor.getColumnNames();
+ int columnCount = cursor.getColumnCount();
+
+ for (int i = 0; i < columnCount; ++i) {
+ System.out.print(fixedWidth(columnWidth, columnNames[i]) + " | ");
+ }
+ System.out.println("(" + cursor.getCount() + " " + tags + ")");
+ for (int i = 0; i < columnCount; ++i) {
+ System.out.print(dashes(columnWidth) + " | ");
+ }
+ System.out.println("");
+ if (!cursor.moveToFirst()) {
+ System.out.println("EMPTY");
+ return;
+ }
+
+ cursor.moveToFirst();
+ while (!cursor.isAfterLast()) {
+ for (int i = 0; i < columnCount; ++i) {
+ System.out.print(fixedWidth(columnWidth, cursor.getString(i)) + " | ");
+ }
+ System.out.println("");
+ cursor.moveToNext();
+ }
+ for (int i = 0; i < columnCount-1; ++i) {
+ System.out.print(dashes(columnWidth + 3));
+ }
+ System.out.print(dashes(columnWidth + 3 - 1));
+ System.out.println("");
+ } finally {
+ cursor.moveToPosition(originalPosition);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/Tab.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/Tab.java
new file mode 100644
index 000000000..f38cfdf0e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/db/Tab.java
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.db;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.Tabs;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.android.RepoUtils;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+
+// Immutable.
+public class Tab {
+ public final String title;
+ public final String icon;
+ public final JSONArray history;
+ public final long lastUsed;
+
+ public Tab(String title, String icon, JSONArray history, long lastUsed) {
+ this.title = title;
+ this.icon = icon;
+ this.history = history;
+ this.lastUsed = lastUsed;
+ }
+
+ public ContentValues toContentValues(String clientGUID, int position) {
+ ContentValues out = new ContentValues();
+ out.put(BrowserContract.Tabs.POSITION, position);
+ out.put(BrowserContract.Tabs.CLIENT_GUID, clientGUID);
+
+ out.put(BrowserContract.Tabs.FAVICON, this.icon);
+ out.put(BrowserContract.Tabs.LAST_USED, this.lastUsed);
+ out.put(BrowserContract.Tabs.TITLE, this.title);
+ out.put(BrowserContract.Tabs.URL, (String) this.history.get(0));
+ out.put(BrowserContract.Tabs.HISTORY, this.history.toJSONString());
+ return out;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Tab)) {
+ return false;
+ }
+ final Tab other = (Tab) o;
+
+ if (!RepoUtils.stringsEqual(this.title, other.title)) {
+ return false;
+ }
+ if (!RepoUtils.stringsEqual(this.icon, other.icon)) {
+ return false;
+ }
+
+ if (!(this.lastUsed == other.lastUsed)) {
+ return false;
+ }
+
+ return Utils.sameArrays(this.history, other.history);
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode();
+ }
+
+ /**
+ * Extract a <code>Tab</code> from a cursor row.
+ * <p>
+ * Caller is responsible for creating, positioning, and closing the cursor.
+ *
+ * @param cursor
+ * to inspect.
+ * @return <code>Tab</code> instance.
+ */
+ public static Tab fromCursor(final Cursor cursor) {
+ final String title = RepoUtils.getStringFromCursor(cursor, Tabs.TITLE);
+ final String icon = RepoUtils.getStringFromCursor(cursor, Tabs.FAVICON);
+ final JSONArray history = RepoUtils.getJSONArrayFromCursor(cursor, Tabs.HISTORY);
+ final long lastUsed = RepoUtils.getLongFromCursor(cursor, Tabs.LAST_USED);
+
+ return new Tab(title, icon, history, lastUsed);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20CreateDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20CreateDelegate.java
new file mode 100644
index 000000000..98809137f
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20CreateDelegate.java
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+
+import java.io.UnsupportedEncodingException;
+import java.security.GeneralSecurityException;
+
+public class FxAccount20CreateDelegate {
+ protected final byte[] emailUTF8;
+ protected final byte[] authPW;
+ protected final boolean preVerified;
+
+ /**
+ * Make a new "create account" delegate.
+ *
+ * @param emailUTF8
+ * email as UTF-8 bytes.
+ * @param quickStretchedPW
+ * quick stretched password as bytes.
+ * @param preVerified
+ * true if account should be marked already verified; only effective
+ * for non-production auth servers.
+ * @throws UnsupportedEncodingException
+ * @throws GeneralSecurityException
+ */
+ public FxAccount20CreateDelegate(byte[] emailUTF8, byte[] quickStretchedPW, boolean preVerified) throws UnsupportedEncodingException, GeneralSecurityException {
+ this.emailUTF8 = emailUTF8;
+ this.authPW = FxAccountUtils.generateAuthPW(quickStretchedPW);
+ this.preVerified = preVerified;
+ }
+
+ public ExtendedJSONObject getCreateBody() throws FxAccountClientException {
+ final ExtendedJSONObject body = new ExtendedJSONObject();
+ try {
+ body.put("email", new String(emailUTF8, "UTF-8"));
+ body.put("authPW", Utils.byte2Hex(authPW));
+ if (preVerified) {
+ // Production endpoints do not allow preVerified; this assumes we only
+ // set it when it's okay to send it.
+ body.put("preVerified", preVerified);
+ }
+ return body;
+ } catch (UnsupportedEncodingException e) {
+ throw new FxAccountClientException(e);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20LoginDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20LoginDelegate.java
new file mode 100644
index 000000000..0266a6eab
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccount20LoginDelegate.java
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+
+import java.io.UnsupportedEncodingException;
+import java.security.GeneralSecurityException;
+
+/**
+ * An abstraction around providing an email and authorization token to the auth
+ * server.
+ */
+public class FxAccount20LoginDelegate {
+ protected final byte[] emailUTF8;
+ protected final byte[] authPW;
+
+ public FxAccount20LoginDelegate(byte[] emailUTF8, byte[] quickStretchedPW) throws UnsupportedEncodingException, GeneralSecurityException {
+ this.emailUTF8 = emailUTF8;
+ this.authPW = FxAccountUtils.generateAuthPW(quickStretchedPW);
+ }
+
+ public ExtendedJSONObject getCreateBody() throws FxAccountClientException {
+ final ExtendedJSONObject body = new ExtendedJSONObject();
+ try {
+ body.put("email", new String(emailUTF8, "UTF-8"));
+ body.put("authPW", Utils.byte2Hex(authPW));
+ return body;
+ } catch (UnsupportedEncodingException e) {
+ throw new FxAccountClientException(e);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java
new file mode 100644
index 000000000..ed959ff0e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa;
+
+import org.mozilla.gecko.background.fxa.FxAccountClient20.AccountStatusResponse;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.RecoveryEmailStatusResponse;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.RequestDelegate;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.TwoKeys;
+import org.mozilla.gecko.fxa.FxAccountDevice;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+import java.util.List;
+
+public interface FxAccountClient {
+ public void accountStatus(String uid, RequestDelegate<AccountStatusResponse> requestDelegate);
+ public void recoveryEmailStatus(byte[] sessionToken, RequestDelegate<RecoveryEmailStatusResponse> requestDelegate);
+ public void keys(byte[] keyFetchToken, RequestDelegate<TwoKeys> requestDelegate);
+ public void sign(byte[] sessionToken, ExtendedJSONObject publicKey, long certificateDurationInMilliseconds, RequestDelegate<String> requestDelegate);
+ public void registerOrUpdateDevice(byte[] sessionToken, FxAccountDevice device, RequestDelegate<FxAccountDevice> requestDelegate);
+ public void deviceList(byte[] sessionToken, RequestDelegate<FxAccountDevice[]> requestDelegate);
+ public void notifyDevices(byte[] sessionToken, List<String> deviceIds, ExtendedJSONObject payload, Long TTL, RequestDelegate<ExtendedJSONObject> requestDelegate);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java
new file mode 100644
index 000000000..596f4525e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java
@@ -0,0 +1,914 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa;
+
+import android.support.annotation.NonNull;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientMalformedResponseException;
+import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.fxa.FxAccountDevice;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.HKDF;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BaseResourceDelegate;
+import org.mozilla.gecko.sync.net.HawkAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.Resource;
+import org.mozilla.gecko.sync.net.SyncResponse;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.Executor;
+
+import javax.crypto.Mac;
+
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.HttpHeaders;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+
+/**
+ * An HTTP client for talking to an FxAccount server.
+ * <p>
+ * <p>
+ * The delegate structure used is a little different from the rest of the code
+ * base. We add a <code>RequestDelegate</code> layer that processes a typed
+ * value extracted from the body of a successful response.
+ */
+public class FxAccountClient20 implements FxAccountClient {
+ protected static final String LOG_TAG = FxAccountClient20.class.getSimpleName();
+
+ protected static final String ACCEPT_HEADER = "application/json;charset=utf-8";
+
+ public static final String JSON_KEY_EMAIL = "email";
+ public static final String JSON_KEY_KEYFETCHTOKEN = "keyFetchToken";
+ public static final String JSON_KEY_SESSIONTOKEN = "sessionToken";
+ public static final String JSON_KEY_UID = "uid";
+ public static final String JSON_KEY_VERIFIED = "verified";
+ public static final String JSON_KEY_ERROR = "error";
+ public static final String JSON_KEY_MESSAGE = "message";
+ public static final String JSON_KEY_INFO = "info";
+ public static final String JSON_KEY_CODE = "code";
+ public static final String JSON_KEY_ERRNO = "errno";
+ public static final String JSON_KEY_EXISTS = "exists";
+
+ protected static final String[] requiredErrorStringFields = { JSON_KEY_ERROR, JSON_KEY_MESSAGE, JSON_KEY_INFO };
+ protected static final String[] requiredErrorLongFields = { JSON_KEY_CODE, JSON_KEY_ERRNO };
+
+ /**
+ * The server's URI.
+ * <p>
+ * We assume throughout that this ends with a trailing slash (and guarantee as
+ * much in the constructor).
+ */
+ protected final String serverURI;
+
+ protected final Executor executor;
+
+ public FxAccountClient20(String serverURI, Executor executor) {
+ if (serverURI == null) {
+ throw new IllegalArgumentException("Must provide a server URI.");
+ }
+ if (executor == null) {
+ throw new IllegalArgumentException("Must provide a non-null executor.");
+ }
+ this.serverURI = serverURI.endsWith("/") ? serverURI : serverURI + "/";
+ if (!this.serverURI.endsWith("/")) {
+ throw new IllegalArgumentException("Constructed serverURI must end with a trailing slash: " + this.serverURI);
+ }
+ this.executor = executor;
+ }
+
+ protected BaseResource getBaseResource(String path, Map<String, String> queryParameters) throws UnsupportedEncodingException, URISyntaxException {
+ if (queryParameters == null || queryParameters.isEmpty()) {
+ return getBaseResource(path);
+ }
+ final String[] array = new String[2 * queryParameters.size()];
+ int i = 0;
+ for (Entry<String, String> entry : queryParameters.entrySet()) {
+ array[i++] = entry.getKey();
+ array[i++] = entry.getValue();
+ }
+ return getBaseResource(path, array);
+ }
+
+ /**
+ * Create <code>BaseResource</code>, encoding query parameters carefully.
+ * <p>
+ * This is equivalent to <code>android.net.Uri.Builder</code>, which is not
+ * present in our JUnit 4 tests.
+ *
+ * @param path fragment.
+ * @param queryParameters list of key/value query parameter pairs. Must be even length!
+ * @return <code>BaseResource<instance>
+ * @throws URISyntaxException
+ * @throws UnsupportedEncodingException
+ */
+ protected BaseResource getBaseResource(String path, String... queryParameters) throws URISyntaxException, UnsupportedEncodingException {
+ final StringBuilder sb = new StringBuilder(serverURI);
+ sb.append(path);
+ if (queryParameters != null) {
+ int i = 0;
+ while (i < queryParameters.length) {
+ sb.append(i > 0 ? "&" : "?");
+ final String key = queryParameters[i++];
+ final String val = queryParameters[i++];
+ sb.append(URLEncoder.encode(key, "UTF-8"));
+ sb.append("=");
+ sb.append(URLEncoder.encode(val, "UTF-8"));
+ }
+ }
+ return new BaseResource(new URI(sb.toString()));
+ }
+
+ /**
+ * Process a typed value extracted from a successful response (in an
+ * endpoint-dependent way).
+ */
+ public interface RequestDelegate<T> {
+ public void handleError(Exception e);
+ public void handleFailure(FxAccountClientRemoteException e);
+ public void handleSuccess(T result);
+ }
+
+ /**
+ * Thin container for two cryptographic keys.
+ */
+ public static class TwoKeys {
+ public final byte[] kA;
+ public final byte[] wrapkB;
+ public TwoKeys(byte[] kA, byte[] wrapkB) {
+ this.kA = kA;
+ this.wrapkB = wrapkB;
+ }
+ }
+
+ protected <T> void invokeHandleError(final RequestDelegate<T> delegate, final Exception e) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.handleError(e);
+ }
+ });
+ }
+
+ enum ResponseType {
+ JSON_ARRAY,
+ JSON_OBJECT
+ }
+
+ /**
+ * Translate resource callbacks into request callbacks invoked on the provided
+ * executor.
+ * <p>
+ * Override <code>handleSuccess</code> to parse the body of the resource
+ * request and call the request callback. <code>handleSuccess</code> is
+ * invoked via the executor, so you don't need to delegate further.
+ */
+ protected abstract class ResourceDelegate<T> extends BaseResourceDelegate {
+
+ protected void handleSuccess(final int status, HttpResponse response, final ExtendedJSONObject body) throws Exception {
+ throw new UnsupportedOperationException();
+ }
+
+ protected void handleSuccess(final int status, HttpResponse response, final JSONArray body) throws Exception {
+ throw new UnsupportedOperationException();
+ }
+
+ protected final RequestDelegate<T> delegate;
+
+ protected final byte[] tokenId;
+ protected final byte[] reqHMACKey;
+ protected final SkewHandler skewHandler;
+ protected final ResponseType responseType;
+
+ /**
+ * Create a delegate for an un-authenticated resource.
+ */
+ public ResourceDelegate(final Resource resource, final RequestDelegate<T> delegate, ResponseType responseType) {
+ this(resource, delegate, responseType, null, null);
+ }
+
+ /**
+ * Create a delegate for a Hawk-authenticated resource.
+ * <p>
+ * Every Hawk request that encloses an entity (PATCH, POST, and PUT) will
+ * include the payload verification hash.
+ */
+ public ResourceDelegate(final Resource resource, final RequestDelegate<T> delegate, ResponseType responseType, final byte[] tokenId, final byte[] reqHMACKey) {
+ super(resource);
+ this.delegate = delegate;
+ this.reqHMACKey = reqHMACKey;
+ this.tokenId = tokenId;
+ this.skewHandler = SkewHandler.getSkewHandlerForResource(resource);
+ this.responseType = responseType;
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ if (tokenId != null && reqHMACKey != null) {
+ // We always include the payload verification hash for FxA Hawk-authenticated requests.
+ final boolean includePayloadVerificationHash = true;
+ return new HawkAuthHeaderProvider(Utils.byte2Hex(tokenId), reqHMACKey, includePayloadVerificationHash, skewHandler.getSkewInSeconds());
+ }
+ return super.getAuthHeaderProvider();
+ }
+
+ @Override
+ public String getUserAgent() {
+ return FxAccountConstants.USER_AGENT;
+ }
+
+ @Override
+ public void handleHttpResponse(HttpResponse response) {
+ try {
+ final int status = validateResponse(response);
+ skewHandler.updateSkew(response, now());
+ invokeHandleSuccess(status, response);
+ } catch (FxAccountClientRemoteException e) {
+ if (!skewHandler.updateSkew(response, now())) {
+ // If we couldn't update skew, but we got a failure, let's try clearing the skew.
+ skewHandler.resetSkew();
+ }
+ invokeHandleFailure(e);
+ }
+ }
+
+ protected void invokeHandleFailure(final FxAccountClientRemoteException e) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.handleFailure(e);
+ }
+ });
+ }
+
+ protected void invokeHandleSuccess(final int status, final HttpResponse response) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ SyncResponse syncResponse = new SyncResponse(response);
+ if (responseType == ResponseType.JSON_ARRAY) {
+ JSONArray body = syncResponse.jsonArrayBody();
+ ResourceDelegate.this.handleSuccess(status, response, body);
+ } else {
+ ExtendedJSONObject body = syncResponse.jsonObjectBody();
+ ResourceDelegate.this.handleSuccess(status, response, body);
+ }
+ } catch (Exception e) {
+ delegate.handleError(e);
+ }
+ }
+ });
+ }
+
+ @Override
+ public void handleHttpProtocolException(final ClientProtocolException e) {
+ invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public void handleHttpIOException(IOException e) {
+ invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public void handleTransportException(GeneralSecurityException e) {
+ invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+ super.addHeaders(request, client);
+
+ // The basics.
+ final Locale locale = Locale.getDefault();
+ request.addHeader(HttpHeaders.ACCEPT_LANGUAGE, Locales.getLanguageTag(locale));
+ request.addHeader(HttpHeaders.ACCEPT, ACCEPT_HEADER);
+ }
+ }
+
+ protected <T> void post(BaseResource resource, final ExtendedJSONObject requestBody) {
+ if (requestBody == null) {
+ resource.post((HttpEntity) null);
+ } else {
+ resource.post(requestBody);
+ }
+ }
+
+ @SuppressWarnings("static-method")
+ public long now() {
+ return System.currentTimeMillis();
+ }
+
+ /**
+ * Intepret a response from the auth server.
+ * <p>
+ * Throw an appropriate exception on errors; otherwise, return the response's
+ * status code.
+ *
+ * @return response's HTTP status code.
+ * @throws FxAccountClientException
+ */
+ public static int validateResponse(HttpResponse response) throws FxAccountClientRemoteException {
+ final int status = response.getStatusLine().getStatusCode();
+ if (status == 200) {
+ return status;
+ }
+ int code;
+ int errno;
+ String error;
+ String message;
+ String info;
+ ExtendedJSONObject body;
+ try {
+ body = new SyncStorageResponse(response).jsonObjectBody();
+ body.throwIfFieldsMissingOrMisTyped(requiredErrorStringFields, String.class);
+ body.throwIfFieldsMissingOrMisTyped(requiredErrorLongFields, Long.class);
+ code = body.getLong(JSON_KEY_CODE).intValue();
+ errno = body.getLong(JSON_KEY_ERRNO).intValue();
+ error = body.getString(JSON_KEY_ERROR);
+ message = body.getString(JSON_KEY_MESSAGE);
+ info = body.getString(JSON_KEY_INFO);
+ } catch (Exception e) {
+ throw new FxAccountClientMalformedResponseException(response);
+ }
+ throw new FxAccountClientRemoteException(response, code, errno, error, message, info, body);
+ }
+
+ /**
+ * Don't call this directly. Use <code>unbundleBody</code> instead.
+ */
+ protected void unbundleBytes(byte[] bundleBytes, byte[] respHMACKey, byte[] respXORKey, byte[]... rest)
+ throws InvalidKeyException, NoSuchAlgorithmException, FxAccountClientException {
+ if (bundleBytes.length < 32) {
+ throw new IllegalArgumentException("input bundle must include HMAC");
+ }
+ int len = respXORKey.length;
+ if (bundleBytes.length != len + 32) {
+ throw new IllegalArgumentException("input bundle and XOR key with HMAC have different lengths");
+ }
+ int left = len;
+ for (byte[] array : rest) {
+ left -= array.length;
+ }
+ if (left != 0) {
+ throw new IllegalArgumentException("XOR key and total output arrays have different lengths");
+ }
+
+ byte[] ciphertext = new byte[len];
+ byte[] HMAC = new byte[32];
+ System.arraycopy(bundleBytes, 0, ciphertext, 0, len);
+ System.arraycopy(bundleBytes, len, HMAC, 0, 32);
+
+ Mac hmacHasher = HKDF.makeHMACHasher(respHMACKey);
+ byte[] computedHMAC = hmacHasher.doFinal(ciphertext);
+ if (!Arrays.equals(computedHMAC, HMAC)) {
+ throw new FxAccountClientException("Bad message HMAC");
+ }
+
+ int offset = 0;
+ for (byte[] array : rest) {
+ for (int i = 0; i < array.length; i++) {
+ array[i] = (byte) (respXORKey[offset + i] ^ ciphertext[offset + i]);
+ }
+ offset += array.length;
+ }
+ }
+
+ protected void unbundleBody(ExtendedJSONObject body, byte[] requestKey, byte[] ctxInfo, byte[]... rest) throws Exception {
+ int length = 0;
+ for (byte[] array : rest) {
+ length += array.length;
+ }
+
+ if (body == null) {
+ throw new FxAccountClientException("body must be non-null");
+ }
+ String bundle = body.getString("bundle");
+ if (bundle == null) {
+ throw new FxAccountClientException("bundle must be a non-null string");
+ }
+ byte[] bundleBytes = Utils.hex2Byte(bundle);
+
+ final byte[] respHMACKey = new byte[32];
+ final byte[] respXORKey = new byte[length];
+ HKDF.deriveMany(requestKey, new byte[0], ctxInfo, respHMACKey, respXORKey);
+ unbundleBytes(bundleBytes, respHMACKey, respXORKey, rest);
+ }
+
+ public void keys(byte[] keyFetchToken, final RequestDelegate<TwoKeys> delegate) {
+ final byte[] tokenId = new byte[32];
+ final byte[] reqHMACKey = new byte[32];
+ final byte[] requestKey = new byte[32];
+ try {
+ HKDF.deriveMany(keyFetchToken, new byte[0], FxAccountUtils.KW("keyFetchToken"), tokenId, reqHMACKey, requestKey);
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ BaseResource resource;
+ try {
+ resource = getBaseResource("account/keys");
+ } catch (URISyntaxException | UnsupportedEncodingException e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<TwoKeys>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
+ byte[] kA = new byte[FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES];
+ byte[] wrapkB = new byte[FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES];
+ unbundleBody(body, requestKey, FxAccountUtils.KW("account/keys"), kA, wrapkB);
+ delegate.handleSuccess(new TwoKeys(kA, wrapkB));
+ }
+ };
+ resource.get();
+ }
+
+ /**
+ * Thin container for account status response.
+ */
+ public static class AccountStatusResponse {
+ public final boolean exists;
+ public AccountStatusResponse(boolean exists) {
+ this.exists = exists;
+ }
+ }
+
+ /**
+ * Query the account status of an account given a uid.
+ *
+ * @param uid to query.
+ * @param delegate to invoke callbacks.
+ */
+ public void accountStatus(String uid, final RequestDelegate<AccountStatusResponse> delegate) {
+ final BaseResource resource;
+ try {
+ final Map<String, String> params = new HashMap<>(1);
+ params.put("uid", uid);
+ resource = getBaseResource("account/status", params);
+ } catch (URISyntaxException | UnsupportedEncodingException e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<AccountStatusResponse>(resource, delegate, ResponseType.JSON_OBJECT) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
+ boolean exists = body.getBoolean(JSON_KEY_EXISTS);
+ delegate.handleSuccess(new AccountStatusResponse(exists));
+ }
+ };
+ resource.get();
+ }
+
+ /**
+ * Thin container for recovery email status response.
+ */
+ public static class RecoveryEmailStatusResponse {
+ public final String email;
+ public final boolean verified;
+ public RecoveryEmailStatusResponse(String email, boolean verified) {
+ this.email = email;
+ this.verified = verified;
+ }
+ }
+
+ /**
+ * Query the recovery email status of an account given a valid session token.
+ * <p>
+ * This API is a little odd: the auth server returns the email and
+ * verification state of the account that corresponds to the (opaque) session
+ * token. It might fail if the session token is unknown (or invalid, or
+ * revoked).
+ *
+ * @param sessionToken
+ * to query.
+ * @param delegate
+ * to invoke callbacks.
+ */
+ public void recoveryEmailStatus(byte[] sessionToken, final RequestDelegate<RecoveryEmailStatusResponse> delegate) {
+ final byte[] tokenId = new byte[32];
+ final byte[] reqHMACKey = new byte[32];
+ final byte[] requestKey = new byte[32];
+ try {
+ HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey);
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ BaseResource resource;
+ try {
+ resource = getBaseResource("recovery_email/status");
+ } catch (URISyntaxException | UnsupportedEncodingException e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<RecoveryEmailStatusResponse>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
+ String[] requiredStringFields = new String[] { JSON_KEY_EMAIL };
+ body.throwIfFieldsMissingOrMisTyped(requiredStringFields, String.class);
+ String email = body.getString(JSON_KEY_EMAIL);
+ Boolean verified = body.getBoolean(JSON_KEY_VERIFIED);
+ delegate.handleSuccess(new RecoveryEmailStatusResponse(email, verified));
+ }
+ };
+ resource.get();
+ }
+
+ @SuppressWarnings("unchecked")
+ public void sign(final byte[] sessionToken, final ExtendedJSONObject publicKey, long durationInMilliseconds, final RequestDelegate<String> delegate) {
+ final ExtendedJSONObject body = new ExtendedJSONObject();
+ body.put("publicKey", publicKey);
+ body.put("duration", durationInMilliseconds);
+
+ final byte[] tokenId = new byte[32];
+ final byte[] reqHMACKey = new byte[32];
+ try {
+ HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey);
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ BaseResource resource;
+ try {
+ resource = getBaseResource("certificate/sign");
+ } catch (URISyntaxException | UnsupportedEncodingException e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<String>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
+ String cert = body.getString("cert");
+ if (cert == null) {
+ delegate.handleError(new FxAccountClientException("cert must be a non-null string"));
+ return;
+ }
+ delegate.handleSuccess(cert);
+ }
+ };
+ post(resource, body);
+ }
+
+ protected static final String[] LOGIN_RESPONSE_REQUIRED_STRING_FIELDS = new String[] { JSON_KEY_UID, JSON_KEY_SESSIONTOKEN };
+ protected static final String[] LOGIN_RESPONSE_REQUIRED_STRING_FIELDS_KEYS = new String[] { JSON_KEY_UID, JSON_KEY_SESSIONTOKEN, JSON_KEY_KEYFETCHTOKEN, };
+ protected static final String[] LOGIN_RESPONSE_REQUIRED_BOOLEAN_FIELDS = new String[] { JSON_KEY_VERIFIED };
+
+ /**
+ * Thin container for login response.
+ * <p>
+ * The <code>remoteEmail</code> field is the email address as normalized by the
+ * server, and is <b>not necessarily</b> the email address delivered to the
+ * <code>login</code> or <code>create</code> call.
+ */
+ public static class LoginResponse {
+ public final String remoteEmail;
+ public final String uid;
+ public final byte[] sessionToken;
+ public final boolean verified;
+ public final byte[] keyFetchToken;
+
+ public LoginResponse(String remoteEmail, String uid, boolean verified, byte[] sessionToken, byte[] keyFetchToken) {
+ this.remoteEmail = remoteEmail;
+ this.uid = uid;
+ this.verified = verified;
+ this.sessionToken = sessionToken;
+ this.keyFetchToken = keyFetchToken;
+ }
+ }
+
+ // Public for testing only; prefer login and loginAndGetKeys (without boolean parameter).
+ public void login(final byte[] emailUTF8, final byte[] quickStretchedPW, final boolean getKeys,
+ final Map<String, String> queryParameters,
+ final RequestDelegate<LoginResponse> delegate) {
+ final BaseResource resource;
+ final ExtendedJSONObject body;
+ try {
+ final String path = "account/login";
+ final Map<String, String> modifiedParameters = new HashMap<>();
+ if (queryParameters != null) {
+ modifiedParameters.putAll(queryParameters);
+ }
+ if (getKeys) {
+ modifiedParameters.put("keys", "true");
+ }
+ resource = getBaseResource(path, modifiedParameters);
+ body = new FxAccount20LoginDelegate(emailUTF8, quickStretchedPW).getCreateBody();
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<LoginResponse>(resource, delegate, ResponseType.JSON_OBJECT) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
+ final String[] requiredStringFields = getKeys ? LOGIN_RESPONSE_REQUIRED_STRING_FIELDS_KEYS : LOGIN_RESPONSE_REQUIRED_STRING_FIELDS;
+ body.throwIfFieldsMissingOrMisTyped(requiredStringFields, String.class);
+
+ final String[] requiredBooleanFields = LOGIN_RESPONSE_REQUIRED_BOOLEAN_FIELDS;
+ body.throwIfFieldsMissingOrMisTyped(requiredBooleanFields, Boolean.class);
+
+ String uid = body.getString(JSON_KEY_UID);
+ boolean verified = body.getBoolean(JSON_KEY_VERIFIED);
+ byte[] sessionToken = Utils.hex2Byte(body.getString(JSON_KEY_SESSIONTOKEN));
+ byte[] keyFetchToken = null;
+ if (getKeys) {
+ keyFetchToken = Utils.hex2Byte(body.getString(JSON_KEY_KEYFETCHTOKEN));
+ }
+ LoginResponse loginResponse = new LoginResponse(new String(emailUTF8, "UTF-8"), uid, verified, sessionToken, keyFetchToken);
+
+ delegate.handleSuccess(loginResponse);
+ }
+ };
+
+ post(resource, body);
+ }
+
+ public void createAccount(final byte[] emailUTF8, final byte[] quickStretchedPW,
+ final boolean getKeys,
+ final boolean preVerified,
+ final Map<String, String> queryParameters,
+ final RequestDelegate<LoginResponse> delegate) {
+ final BaseResource resource;
+ final ExtendedJSONObject body;
+ try {
+ final String path = "account/create";
+ final Map<String, String> modifiedParameters = new HashMap<>();
+ if (queryParameters != null) {
+ modifiedParameters.putAll(queryParameters);
+ }
+ if (getKeys) {
+ modifiedParameters.put("keys", "true");
+ }
+ resource = getBaseResource(path, modifiedParameters);
+ body = new FxAccount20CreateDelegate(emailUTF8, quickStretchedPW, preVerified).getCreateBody();
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ // This is very similar to login, except verified is not required.
+ resource.delegate = new ResourceDelegate<LoginResponse>(resource, delegate, ResponseType.JSON_OBJECT) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
+ final String[] requiredStringFields = getKeys ? LOGIN_RESPONSE_REQUIRED_STRING_FIELDS_KEYS : LOGIN_RESPONSE_REQUIRED_STRING_FIELDS;
+ body.throwIfFieldsMissingOrMisTyped(requiredStringFields, String.class);
+
+ String uid = body.getString(JSON_KEY_UID);
+ boolean verified = false; // In production, we're definitely not verified immediately upon creation.
+ Boolean tempVerified = body.getBoolean(JSON_KEY_VERIFIED);
+ if (tempVerified != null) {
+ verified = tempVerified;
+ }
+ byte[] sessionToken = Utils.hex2Byte(body.getString(JSON_KEY_SESSIONTOKEN));
+ byte[] keyFetchToken = null;
+ if (getKeys) {
+ keyFetchToken = Utils.hex2Byte(body.getString(JSON_KEY_KEYFETCHTOKEN));
+ }
+ LoginResponse loginResponse = new LoginResponse(new String(emailUTF8, "UTF-8"), uid, verified, sessionToken, keyFetchToken);
+
+ delegate.handleSuccess(loginResponse);
+ }
+ };
+
+ post(resource, body);
+ }
+
+ /**
+ * We want users to be able to enter their email address case-insensitively.
+ * We stretch the password locally using the email address as a salt, to make
+ * dictionary attacks more expensive. This means that a client with a
+ * case-differing email address is unable to produce the correct
+ * authorization, even though it knows the password. In this case, the server
+ * returns the email that the account was created with, so that the client can
+ * re-stretch the password locally with the correct email salt. This version
+ * of <code>login</code> retries at most one time with a server provided email
+ * address.
+ * <p>
+ * Be aware that consumers will not see the initial error response from the
+ * server providing an alternate email (if there is one).
+ *
+ * @param emailUTF8
+ * user entered email address.
+ * @param stretcher
+ * delegate to stretch and re-stretch password.
+ * @param getKeys
+ * true if a <code>keyFetchToken</code> should be returned (in
+ * addition to the standard <code>sessionToken</code>).
+ * @param queryParameters
+ * @param delegate
+ * to invoke callbacks.
+ */
+ public void login(final byte[] emailUTF8, final PasswordStretcher stretcher, final boolean getKeys,
+ final Map<String, String> queryParameters,
+ final RequestDelegate<LoginResponse> delegate) {
+ byte[] quickStretchedPW;
+ try {
+ FxAccountUtils.pii(LOG_TAG, "Trying user provided email: '" + new String(emailUTF8, "UTF-8") + "'" );
+ quickStretchedPW = stretcher.getQuickStretchedPW(emailUTF8);
+ } catch (Exception e) {
+ delegate.handleError(e);
+ return;
+ }
+
+ this.login(emailUTF8, quickStretchedPW, getKeys, queryParameters, new RequestDelegate<LoginResponse>() {
+ @Override
+ public void handleSuccess(LoginResponse result) {
+ delegate.handleSuccess(result);
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ delegate.handleError(e);
+ }
+
+ @Override
+ public void handleFailure(FxAccountClientRemoteException e) {
+ String alternateEmail = e.body.getString(JSON_KEY_EMAIL);
+ if (!e.isBadEmailCase() || alternateEmail == null) {
+ delegate.handleFailure(e);
+ return;
+ };
+
+ Logger.info(LOG_TAG, "Server returned alternate email; retrying login with provided email.");
+ FxAccountUtils.pii(LOG_TAG, "Trying server provided email: '" + alternateEmail + "'" );
+
+ try {
+ // Nota bene: this is not recursive, since we call the fixed password
+ // signature here, which invokes a non-retrying version.
+ byte[] alternateEmailUTF8 = alternateEmail.getBytes("UTF-8");
+ byte[] alternateQuickStretchedPW = stretcher.getQuickStretchedPW(alternateEmailUTF8);
+ login(alternateEmailUTF8, alternateQuickStretchedPW, getKeys, queryParameters, delegate);
+ } catch (Exception innerException) {
+ delegate.handleError(innerException);
+ return;
+ }
+ }
+ });
+ }
+
+ /**
+ * Registers a device given a valid session token.
+ *
+ * @param sessionToken to query.
+ * @param delegate to invoke callbacks.
+ */
+ @Override
+ public void registerOrUpdateDevice(byte[] sessionToken, FxAccountDevice device, RequestDelegate<FxAccountDevice> delegate) {
+ final byte[] tokenId = new byte[32];
+ final byte[] reqHMACKey = new byte[32];
+ final byte[] requestKey = new byte[32];
+ try {
+ HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey);
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ final BaseResource resource;
+ final ExtendedJSONObject body;
+ try {
+ resource = getBaseResource("account/device");
+ body = device.toJson();
+ } catch (URISyntaxException | UnsupportedEncodingException e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<FxAccountDevice>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+ try {
+ delegate.handleSuccess(FxAccountDevice.fromJson(body));
+ } catch (Exception e) {
+ delegate.handleError(e);
+ }
+ }
+ };
+
+ post(resource, body);
+ }
+
+ @Override
+ public void deviceList(byte[] sessionToken, RequestDelegate<FxAccountDevice[]> delegate) {
+ final byte[] tokenId = new byte[32];
+ final byte[] reqHMACKey = new byte[32];
+ final byte[] requestKey = new byte[32];
+ try {
+ HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey);
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ final BaseResource resource;
+ try {
+ resource = getBaseResource("account/devices");
+ } catch (URISyntaxException | UnsupportedEncodingException e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<FxAccountDevice[]>(resource, delegate, ResponseType.JSON_ARRAY, tokenId, reqHMACKey) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, JSONArray devicesJson) {
+ try {
+ FxAccountDevice[] devices = new FxAccountDevice[devicesJson.size()];
+ for (int i = 0; i < devices.length; i++) {
+ ExtendedJSONObject deviceJson = new ExtendedJSONObject((JSONObject) devicesJson.get(i));
+ devices[i] = FxAccountDevice.fromJson(deviceJson);
+ }
+ delegate.handleSuccess(devices);
+ } catch (Exception e) {
+ delegate.handleError(e);
+ }
+ }
+ };
+
+ resource.get();
+ }
+
+ @Override
+ public void notifyDevices(@NonNull byte[] sessionToken, @NonNull List<String> deviceIds, ExtendedJSONObject payload, Long TTL, RequestDelegate<ExtendedJSONObject> delegate) {
+ final byte[] tokenId = new byte[32];
+ final byte[] reqHMACKey = new byte[32];
+ final byte[] requestKey = new byte[32];
+ try {
+ HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey);
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ final BaseResource resource;
+ final ExtendedJSONObject body = createNotifyDevicesBody(deviceIds, payload, TTL);
+ try {
+ resource = getBaseResource("account/devices/notify");
+ } catch (URISyntaxException | UnsupportedEncodingException e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<ExtendedJSONObject>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+ try {
+ delegate.handleSuccess(body);
+ } catch (Exception e) {
+ delegate.handleError(e);
+ }
+ }
+ };
+
+ post(resource, body);
+ }
+
+ @NonNull
+ @SuppressWarnings("unchecked")
+ private ExtendedJSONObject createNotifyDevicesBody(@NonNull List<String> deviceIds, ExtendedJSONObject payload, Long TTL) {
+ final ExtendedJSONObject body = new ExtendedJSONObject();
+ final JSONArray to = new JSONArray();
+ to.addAll(deviceIds);
+ body.put("to", to);
+ if (payload != null) {
+ body.put("payload", payload);
+ }
+ if (TTL != null) {
+ body.put("TTL", TTL);
+ }
+ return body;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClientException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClientException.java
new file mode 100644
index 000000000..28ee5630e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClientException.java
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.HttpStatus;
+
+/**
+ * From <a href="https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md">https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md</a>.
+ */
+public class FxAccountClientException extends Exception {
+ private static final long serialVersionUID = 7953459541558266597L;
+
+ public FxAccountClientException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public FxAccountClientException(Exception e) {
+ super(e);
+ }
+
+ public static class FxAccountClientRemoteException extends FxAccountClientException {
+ private static final long serialVersionUID = 2209313149952001097L;
+
+ public final HttpResponse response;
+ public final long httpStatusCode;
+ public final long apiErrorNumber;
+ public final String error;
+ public final String message;
+ public final String info;
+ public final ExtendedJSONObject body;
+
+ public FxAccountClientRemoteException(HttpResponse response, long httpStatusCode, long apiErrorNumber, String error, String message, String info, ExtendedJSONObject body) {
+ super(new HTTPFailureException(new SyncStorageResponse(response)));
+ if (body == null) {
+ throw new IllegalArgumentException("body must not be null");
+ }
+ this.response = response;
+ this.httpStatusCode = httpStatusCode;
+ this.apiErrorNumber = apiErrorNumber;
+ this.error = error;
+ this.message = message;
+ this.info = info;
+ this.body = body;
+ }
+
+ @Override
+ public String toString() {
+ return "<FxAccountClientRemoteException " + this.httpStatusCode + " [" + this.apiErrorNumber + "]: " + this.message + ">";
+ }
+
+ public boolean isInvalidAuthentication() {
+ return httpStatusCode == HttpStatus.SC_UNAUTHORIZED;
+ }
+
+ public boolean isAccountAlreadyExists() {
+ return apiErrorNumber == FxAccountRemoteError.ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS;
+ }
+
+ public boolean isAccountDoesNotExist() {
+ return apiErrorNumber == FxAccountRemoteError.ATTEMPT_TO_ACCESS_AN_ACCOUNT_THAT_DOES_NOT_EXIST;
+ }
+
+ public boolean isBadPassword() {
+ return apiErrorNumber == FxAccountRemoteError.INCORRECT_PASSWORD;
+ }
+
+ public boolean isUnverified() {
+ return apiErrorNumber == FxAccountRemoteError.ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT;
+ }
+
+ public boolean isUpgradeRequired() {
+ return
+ apiErrorNumber == FxAccountRemoteError.ENDPOINT_IS_NO_LONGER_SUPPORTED ||
+ apiErrorNumber == FxAccountRemoteError.INCORRECT_LOGIN_METHOD_FOR_THIS_ACCOUNT ||
+ apiErrorNumber == FxAccountRemoteError.INCORRECT_KEY_RETRIEVAL_METHOD_FOR_THIS_ACCOUNT ||
+ apiErrorNumber == FxAccountRemoteError.INCORRECT_API_VERSION_FOR_THIS_ACCOUNT;
+ }
+
+ public boolean isTooManyRequests() {
+ return apiErrorNumber == FxAccountRemoteError.CLIENT_HAS_SENT_TOO_MANY_REQUESTS;
+ }
+
+ public boolean isServerUnavailable() {
+ return apiErrorNumber == FxAccountRemoteError.SERVICE_TEMPORARILY_UNAVAILABLE_DUE_TO_HIGH_LOAD;
+ }
+
+ public boolean isBadEmailCase() {
+ return apiErrorNumber == FxAccountRemoteError.INCORRECT_EMAIL_CASE;
+ }
+
+ public boolean isAccountLocked() {
+ return apiErrorNumber == FxAccountRemoteError.ACCOUNT_LOCKED;
+ }
+
+ public int getErrorMessageStringResource() {
+ if (isUpgradeRequired()) {
+ return R.string.fxaccount_remote_error_UPGRADE_REQUIRED;
+ } else if (isAccountAlreadyExists()) {
+ return R.string.fxaccount_remote_error_ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS;
+ } else if (isAccountDoesNotExist()) {
+ return R.string.fxaccount_remote_error_ATTEMPT_TO_ACCESS_AN_ACCOUNT_THAT_DOES_NOT_EXIST;
+ } else if (isBadPassword()) {
+ return R.string.fxaccount_remote_error_INCORRECT_PASSWORD;
+ } else if (isUnverified()) {
+ return R.string.fxaccount_remote_error_ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT;
+ } else if (isTooManyRequests()) {
+ return R.string.fxaccount_remote_error_CLIENT_HAS_SENT_TOO_MANY_REQUESTS;
+ } else if (isServerUnavailable()) {
+ return R.string.fxaccount_remote_error_SERVICE_TEMPORARILY_UNAVAILABLE_TO_DUE_HIGH_LOAD;
+ } else if (isAccountLocked()) {
+ return R.string.fxaccount_remote_error_ACCOUNT_LOCKED;
+ } else {
+ return R.string.fxaccount_remote_error_UNKNOWN_ERROR;
+ }
+ }
+ }
+
+ public static class FxAccountClientMalformedResponseException extends FxAccountClientRemoteException {
+ private static final long serialVersionUID = 2209313149952001098L;
+
+ public FxAccountClientMalformedResponseException(HttpResponse response) {
+ super(response, 0, FxAccountRemoteError.UNKNOWN_ERROR, "Response malformed", "Response malformed", "Response malformed", new ExtendedJSONObject());
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountRemoteError.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountRemoteError.java
new file mode 100644
index 000000000..5a89561cb
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountRemoteError.java
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa;
+
+public interface FxAccountRemoteError {
+ public static final int ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS = 101;
+ public static final int ATTEMPT_TO_ACCESS_AN_ACCOUNT_THAT_DOES_NOT_EXIST = 102;
+ public static final int INCORRECT_PASSWORD = 103;
+ public static final int ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT = 104;
+ public static final int INVALID_VERIFICATION_CODE = 105;
+ public static final int REQUEST_BODY_WAS_NOT_VALID_JSON = 106;
+ public static final int REQUEST_BODY_CONTAINS_INVALID_PARAMETERS = 107;
+ public static final int REQUEST_BODY_MISSING_REQUIRED_PARAMETERS = 108;
+ public static final int INVALID_REQUEST_SIGNATURE = 109;
+ public static final int INVALID_AUTHENTICATION_TOKEN = 110;
+ public static final int INVALID_AUTHENTICATION_TIMESTAMP = 111;
+ public static final int CONTENT_LENGTH_HEADER_WAS_NOT_PROVIDED = 112;
+ public static final int REQUEST_BODY_TOO_LARGE = 113;
+ public static final int CLIENT_HAS_SENT_TOO_MANY_REQUESTS = 114;
+ public static final int INVALID_NONCE_IN_REQUEST_SIGNATURE = 115;
+ public static final int ENDPOINT_IS_NO_LONGER_SUPPORTED = 116;
+ public static final int INCORRECT_LOGIN_METHOD_FOR_THIS_ACCOUNT = 117;
+ public static final int INCORRECT_KEY_RETRIEVAL_METHOD_FOR_THIS_ACCOUNT = 118;
+ public static final int INCORRECT_API_VERSION_FOR_THIS_ACCOUNT = 119;
+ public static final int INCORRECT_EMAIL_CASE = 120;
+ public static final int ACCOUNT_LOCKED = 121;
+ public static final int UNKNOWN_DEVICE = 123;
+ public static final int DEVICE_SESSION_CONFLICT = 124;
+ public static final int SERVICE_TEMPORARILY_UNAVAILABLE_DUE_TO_HIGH_LOAD = 201;
+ public static final int UNKNOWN_ERROR = 999;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountUtils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountUtils.java
new file mode 100644
index 000000000..2d29725a0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountUtils.java
@@ -0,0 +1,217 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa;
+
+import java.io.UnsupportedEncodingException;
+import java.math.BigInteger;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.nativecode.NativeCrypto;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.HKDF;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.crypto.PBKDF2;
+
+import android.content.Context;
+
+public class FxAccountUtils {
+ private static final String LOG_TAG = FxAccountUtils.class.getSimpleName();
+
+ public static final int SALT_LENGTH_BYTES = 32;
+ public static final int SALT_LENGTH_HEX = 2 * SALT_LENGTH_BYTES;
+
+ public static final int HASH_LENGTH_BYTES = 16;
+ public static final int HASH_LENGTH_HEX = 2 * HASH_LENGTH_BYTES;
+
+ public static final int CRYPTO_KEY_LENGTH_BYTES = 32;
+ public static final int CRYPTO_KEY_LENGTH_HEX = 2 * CRYPTO_KEY_LENGTH_BYTES;
+
+ public static final String KW_VERSION_STRING = "identity.mozilla.com/picl/v1/";
+
+ public static final int NUMBER_OF_QUICK_STRETCH_ROUNDS = 1000;
+
+ // For extra debugging. Not final so it can be changed from Fennec, or from
+ // an add-on.
+ public static boolean LOG_PERSONAL_INFORMATION = false;
+
+ public static void pii(String tag, String message) {
+ if (FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ Logger.info(tag, "$$FxA PII$$: " + message);
+ }
+ }
+
+ public static String bytes(String string) throws UnsupportedEncodingException {
+ return Utils.byte2Hex(string.getBytes("UTF-8"));
+ }
+
+ public static byte[] KW(String name) throws UnsupportedEncodingException {
+ return Utils.concatAll(
+ KW_VERSION_STRING.getBytes("UTF-8"),
+ name.getBytes("UTF-8"));
+ }
+
+ public static byte[] KWE(String name, byte[] emailUTF8) throws UnsupportedEncodingException {
+ return Utils.concatAll(
+ KW_VERSION_STRING.getBytes("UTF-8"),
+ name.getBytes("UTF-8"),
+ ":".getBytes("UTF-8"),
+ emailUTF8);
+ }
+
+ /**
+ * Calculate the SRP verifier <tt>x</tt> value.
+ */
+ public static BigInteger srpVerifierLowercaseX(byte[] emailUTF8, byte[] srpPWBytes, byte[] srpSaltBytes)
+ throws NoSuchAlgorithmException, UnsupportedEncodingException {
+ byte[] inner = Utils.sha256(Utils.concatAll(emailUTF8, ":".getBytes("UTF-8"), srpPWBytes));
+ byte[] outer = Utils.sha256(Utils.concatAll(srpSaltBytes, inner));
+ return new BigInteger(1, outer);
+ }
+
+ /**
+ * Calculate the SRP verifier <tt>v</tt> value.
+ */
+ public static BigInteger srpVerifierLowercaseV(byte[] emailUTF8, byte[] srpPWBytes, byte[] srpSaltBytes, BigInteger g, BigInteger N)
+ throws NoSuchAlgorithmException, UnsupportedEncodingException {
+ BigInteger x = srpVerifierLowercaseX(emailUTF8, srpPWBytes, srpSaltBytes);
+ BigInteger v = g.modPow(x, N);
+ return v;
+ }
+
+ /**
+ * Format x modulo N in hexadecimal, using as many characters as N takes (in hexadecimal).
+ * @param x to format.
+ * @param N modulus.
+ * @return x modulo N in hexadecimal.
+ */
+ public static String hexModN(BigInteger x, BigInteger N) {
+ int byteLength = (N.bitLength() + 7) / 8;
+ int hexLength = 2 * byteLength;
+ return Utils.byte2Hex(Utils.hex2Byte((x.mod(N)).toString(16), byteLength), hexLength);
+ }
+
+ /**
+ * The first engineering milestone of PICL (Profile-in-the-Cloud) was
+ * comprised of Sync 1.1 fronted by a Firefox Account. The sync key was
+ * generated from the Firefox Account password-derived kB value using this
+ * method.
+ */
+ public static KeyBundle generateSyncKeyBundle(final byte[] kB) throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
+ byte[] encryptionKey = new byte[32];
+ byte[] hmacKey = new byte[32];
+ byte[] derived = HKDF.derive(kB, new byte[0], FxAccountUtils.KW("oldsync"), 2*32);
+ System.arraycopy(derived, 0*32, encryptionKey, 0, 1*32);
+ System.arraycopy(derived, 1*32, hmacKey, 0, 1*32);
+ return new KeyBundle(encryptionKey, hmacKey);
+ }
+
+ /**
+ * Firefox Accounts are password authenticated, but clients should not store
+ * the plain-text password for any amount of time. Equivalent, but slightly
+ * more secure, is the quickly client-side stretched password.
+ * <p>
+ * We separate this since multiple login-time operations want it, and the
+ * PBKDF2 operation is computationally expensive.
+ */
+ public static byte[] generateQuickStretchedPW(byte[] emailUTF8, byte[] passwordUTF8) throws GeneralSecurityException, UnsupportedEncodingException {
+ byte[] S = FxAccountUtils.KWE("quickStretch", emailUTF8);
+ try {
+ return NativeCrypto.pbkdf2SHA256(passwordUTF8, S, NUMBER_OF_QUICK_STRETCH_ROUNDS, 32);
+ } catch (final LinkageError e) {
+ // This will throw UnsatisfiedLinkError (missing mozglue) the first time it is called, and
+ // ClassNotDefFoundError, for the uninitialized NativeCrypto class, each subsequent time this
+ // is called; LinkageError is their common ancestor.
+ Logger.warn(LOG_TAG, "Got throwable stretching password using native pbkdf2SHA256 " +
+ "implementation; ignoring and using Java implementation.", e);
+ return PBKDF2.pbkdf2SHA256(passwordUTF8, S, NUMBER_OF_QUICK_STRETCH_ROUNDS, 32);
+ }
+ }
+
+ /**
+ * The password-derived credential used to authenticate to the Firefox Account
+ * auth server.
+ */
+ public static byte[] generateAuthPW(byte[] quickStretchedPW) throws GeneralSecurityException, UnsupportedEncodingException {
+ return HKDF.derive(quickStretchedPW, new byte[0], FxAccountUtils.KW("authPW"), 32);
+ }
+
+ /**
+ * The password-derived credential used to unwrap keys managed by the Firefox
+ * Account auth server.
+ */
+ public static byte[] generateUnwrapBKey(byte[] quickStretchedPW) throws GeneralSecurityException, UnsupportedEncodingException {
+ return HKDF.derive(quickStretchedPW, new byte[0], FxAccountUtils.KW("unwrapBkey"), 32);
+ }
+
+ public static byte[] unwrapkB(byte[] unwrapkB, byte[] wrapkB) {
+ if (unwrapkB == null) {
+ throw new IllegalArgumentException("unwrapkB must not be null");
+ }
+ if (wrapkB == null) {
+ throw new IllegalArgumentException("wrapkB must not be null");
+ }
+ if (unwrapkB.length != CRYPTO_KEY_LENGTH_BYTES || wrapkB.length != CRYPTO_KEY_LENGTH_BYTES) {
+ throw new IllegalArgumentException("unwrapkB and wrapkB must be " + CRYPTO_KEY_LENGTH_BYTES + " bytes long");
+ }
+ byte[] kB = new byte[CRYPTO_KEY_LENGTH_BYTES];
+ for (int i = 0; i < wrapkB.length; i++) {
+ kB[i] = (byte) (wrapkB[i] ^ unwrapkB[i]);
+ }
+ return kB;
+ }
+
+ /**
+ * The token server accepts an X-Client-State header, which is the
+ * lowercase-hex-encoded first 16 bytes of the SHA-256 hash of the
+ * bytes of kB.
+ * @param kB a byte array, expected to be 32 bytes long.
+ * @return a 32-character string.
+ * @throws NoSuchAlgorithmException
+ */
+ public static String computeClientState(byte[] kB) throws NoSuchAlgorithmException {
+ if (kB == null ||
+ kB.length != 32) {
+ throw new IllegalArgumentException("Unexpected kB.");
+ }
+ byte[] sha256 = Utils.sha256(kB);
+ byte[] truncated = new byte[16];
+ System.arraycopy(sha256, 0, truncated, 0, 16);
+ return Utils.byte2Hex(truncated); // This is automatically lowercase.
+ }
+
+ /**
+ * Given an endpoint, calculate the corresponding BrowserID audience.
+ * <p>
+ * This is the domain, in web parlance.
+ *
+ * @param serverURI endpoint.
+ * @return BrowserID audience.
+ * @throws URISyntaxException
+ */
+ public static String getAudienceForURL(String serverURI) throws URISyntaxException {
+ URI uri = new URI(serverURI);
+ return new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), null, null, null).toString();
+ }
+
+ public static String defaultClientName(Context context) {
+ String name = AppConstants.MOZ_APP_DISPLAYNAME; // The display name is never translated.
+ // Change "Firefox Aurora" or similar into "Aurora".
+ if (name.contains("Aurora")) {
+ name = "Aurora";
+ } else if (name.contains("Beta")) {
+ name = "Beta";
+ } else if (name.contains("Nightly")) {
+ name = "Nightly";
+ }
+ return context.getResources().getString(R.string.sync_default_client_name, name, android.os.Build.MODEL);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/PasswordStretcher.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/PasswordStretcher.java
new file mode 100644
index 000000000..2debf3c77
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/PasswordStretcher.java
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa;
+
+import java.io.UnsupportedEncodingException;
+import java.security.GeneralSecurityException;
+
+public interface PasswordStretcher {
+ public byte[] getQuickStretchedPW(byte[] emailUTF8) throws UnsupportedEncodingException, GeneralSecurityException;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/QuickPasswordStretcher.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/QuickPasswordStretcher.java
new file mode 100644
index 000000000..bf4b1bc97
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/QuickPasswordStretcher.java
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa;
+
+import java.io.UnsupportedEncodingException;
+import java.security.GeneralSecurityException;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.mozilla.gecko.sync.Utils;
+
+public class QuickPasswordStretcher implements PasswordStretcher {
+ protected final String password;
+ protected final Map<String, String> cache = new HashMap<String, String>();
+
+ public QuickPasswordStretcher(String password) {
+ this.password = password;
+ }
+
+ @Override
+ public synchronized byte[] getQuickStretchedPW(byte[] emailUTF8) throws UnsupportedEncodingException, GeneralSecurityException {
+ if (emailUTF8 == null) {
+ throw new IllegalArgumentException("emailUTF8 must not be null");
+ }
+ String key = Utils.byte2Hex(emailUTF8);
+ if (!cache.containsKey(key)) {
+ byte[] value = FxAccountUtils.generateQuickStretchedPW(emailUTF8, password.getBytes("UTF-8"));
+ cache.put(key, Utils.byte2Hex(value));
+ return value;
+ }
+ return Utils.hex2Byte(cache.get(key));
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/SkewHandler.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/SkewHandler.java
new file mode 100644
index 000000000..9d0ad5e03
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/SkewHandler.java
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.HashMap;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.net.Resource;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.HttpHeaders;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.impl.cookie.DateParseException;
+import ch.boye.httpclientandroidlib.impl.cookie.DateUtils;
+
+public class SkewHandler {
+ private static final String LOG_TAG = "SkewHandler";
+ protected volatile long skewMillis = 0L;
+ protected final String hostname;
+
+ private static final HashMap<String, SkewHandler> skewHandlers = new HashMap<String, SkewHandler>();
+
+ public static SkewHandler getSkewHandlerForResource(final Resource resource) {
+ return getSkewHandlerForHostname(resource.getHostname());
+ }
+
+ public static SkewHandler getSkewHandlerFromEndpointString(final String url) throws URISyntaxException {
+ if (url == null) {
+ throw new IllegalArgumentException("url must not be null.");
+ }
+ URI u = new URI(url);
+ return getSkewHandlerForHostname(u.getHost());
+ }
+
+ public static synchronized SkewHandler getSkewHandlerForHostname(final String hostname) {
+ SkewHandler handler = skewHandlers.get(hostname);
+ if (handler == null) {
+ handler = new SkewHandler(hostname);
+ skewHandlers.put(hostname, handler);
+ }
+ return handler;
+ }
+
+ public static synchronized void clearSkewHandlers() {
+ skewHandlers.clear();
+ }
+
+ public SkewHandler(final String hostname) {
+ this.hostname = hostname;
+ }
+
+ public boolean updateSkewFromServerMillis(long millis, long now) {
+ skewMillis = millis - now;
+ Logger.debug(LOG_TAG, "Updated skew: " + skewMillis + "ms for hostname " + this.hostname);
+ return true;
+ }
+
+ public boolean updateSkewFromHTTPDateString(String date, long now) {
+ try {
+ final long millis = DateUtils.parseDate(date).getTime();
+ return updateSkewFromServerMillis(millis, now);
+ } catch (DateParseException e) {
+ Logger.warn(LOG_TAG, "Unexpected: invalid Date header from " + this.hostname);
+ return false;
+ }
+ }
+
+ public boolean updateSkewFromDateHeader(Header header, long now) {
+ String date = header.getValue();
+ if (null == date) {
+ Logger.warn(LOG_TAG, "Unexpected: null Date header from " + this.hostname);
+ return false;
+ }
+ return updateSkewFromHTTPDateString(date, now);
+ }
+
+ /**
+ * Update our tracked skew value to account for the local clock differing from
+ * the server's.
+ *
+ * @param response
+ * the received HTTP response.
+ * @param now
+ * the current time in milliseconds.
+ * @return true if the skew value was updated, false otherwise.
+ */
+ public boolean updateSkew(HttpResponse response, long now) {
+ Header header = response.getFirstHeader(HttpHeaders.DATE);
+ if (null == header) {
+ Logger.warn(LOG_TAG, "Unexpected: missing Date header from " + this.hostname);
+ return false;
+ }
+ return updateSkewFromDateHeader(header, now);
+ }
+
+ public long getSkewInMillis() {
+ return skewMillis;
+ }
+
+ public long getSkewInSeconds() {
+ return skewMillis / 1000;
+ }
+
+ public void resetSkew() {
+ skewMillis = 0L;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClient.java
new file mode 100644
index 000000000..4bdaa6690
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClient.java
@@ -0,0 +1,224 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa.oauth;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.util.Locale;
+import java.util.concurrent.Executor;
+
+import org.mozilla.gecko.background.fxa.FxAccountClientException;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientMalformedResponseException;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BaseResourceDelegate;
+import org.mozilla.gecko.sync.net.Resource;
+import org.mozilla.gecko.sync.net.SyncResponse;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.HttpHeaders;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+
+public abstract class FxAccountAbstractClient {
+ protected static final String LOG_TAG = FxAccountAbstractClient.class.getSimpleName();
+
+ protected static final String ACCEPT_HEADER = "application/json;charset=utf-8";
+ protected static final String AUTHORIZATION_RESPONSE_TYPE = "token";
+
+ public static final String JSON_KEY_ERROR = "error";
+ public static final String JSON_KEY_MESSAGE = "message";
+ public static final String JSON_KEY_CODE = "code";
+ public static final String JSON_KEY_ERRNO = "errno";
+
+ protected static final String[] requiredErrorStringFields = { JSON_KEY_ERROR, JSON_KEY_MESSAGE };
+ protected static final String[] requiredErrorLongFields = { JSON_KEY_CODE, JSON_KEY_ERRNO };
+
+ /**
+ * The server's URI.
+ * <p>
+ * We assume throughout that this ends with a trailing slash (and guarantee as
+ * much in the constructor).
+ */
+ protected final String serverURI;
+
+ protected final Executor executor;
+
+ public FxAccountAbstractClient(String serverURI, Executor executor) {
+ if (serverURI == null) {
+ throw new IllegalArgumentException("Must provide a server URI.");
+ }
+ if (executor == null) {
+ throw new IllegalArgumentException("Must provide a non-null executor.");
+ }
+ this.serverURI = serverURI.endsWith("/") ? serverURI : serverURI + "/";
+ if (!this.serverURI.endsWith("/")) {
+ throw new IllegalArgumentException("Constructed serverURI must end with a trailing slash: " + this.serverURI);
+ }
+ this.executor = executor;
+ }
+
+ /**
+ * Process a typed value extracted from a successful response (in an
+ * endpoint-dependent way).
+ */
+ public interface RequestDelegate<T> {
+ public void handleError(Exception e);
+ public void handleFailure(FxAccountAbstractClientRemoteException e);
+ public void handleSuccess(T result);
+ }
+
+ /**
+ * Intepret a response from the auth server.
+ * <p>
+ * Throw an appropriate exception on errors; otherwise, return the response's
+ * status code.
+ *
+ * @return response's HTTP status code.
+ * @throws FxAccountClientException
+ */
+ public static int validateResponse(HttpResponse response) throws FxAccountAbstractClientRemoteException {
+ final int status = response.getStatusLine().getStatusCode();
+ if (status == 200) {
+ return status;
+ }
+ int code;
+ int errno;
+ String error;
+ String message;
+ ExtendedJSONObject body;
+ try {
+ body = new SyncStorageResponse(response).jsonObjectBody();
+ body.throwIfFieldsMissingOrMisTyped(requiredErrorStringFields, String.class);
+ body.throwIfFieldsMissingOrMisTyped(requiredErrorLongFields, Long.class);
+ code = body.getLong(JSON_KEY_CODE).intValue();
+ errno = body.getLong(JSON_KEY_ERRNO).intValue();
+ error = body.getString(JSON_KEY_ERROR);
+ message = body.getString(JSON_KEY_MESSAGE);
+ } catch (Exception e) {
+ throw new FxAccountAbstractClientMalformedResponseException(response);
+ }
+ throw new FxAccountAbstractClientRemoteException(response, code, errno, error, message, body);
+ }
+
+ protected <T> void invokeHandleError(final RequestDelegate<T> delegate, final Exception e) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.handleError(e);
+ }
+ });
+ }
+
+ protected <T> void post(BaseResource resource, final ExtendedJSONObject requestBody, final RequestDelegate<T> delegate) {
+ try {
+ if (requestBody == null) {
+ resource.post((HttpEntity) null);
+ } else {
+ resource.post(requestBody);
+ }
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+ }
+
+ /**
+ * Translate resource callbacks into request callbacks invoked on the provided
+ * executor.
+ * <p>
+ * Override <code>handleSuccess</code> to parse the body of the resource
+ * request and call the request callback. <code>handleSuccess</code> is
+ * invoked via the executor, so you don't need to delegate further.
+ */
+ protected abstract class ResourceDelegate<T> extends BaseResourceDelegate {
+ protected abstract void handleSuccess(final int status, HttpResponse response, final ExtendedJSONObject body);
+
+ protected final RequestDelegate<T> delegate;
+
+ /**
+ * Create a delegate for an un-authenticated resource.
+ */
+ public ResourceDelegate(final Resource resource, final RequestDelegate<T> delegate) {
+ super(resource);
+ this.delegate = delegate;
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return super.getAuthHeaderProvider();
+ }
+
+ @Override
+ public String getUserAgent() {
+ return FxAccountConstants.USER_AGENT;
+ }
+
+ @Override
+ public void handleHttpResponse(HttpResponse response) {
+ try {
+ final int status = validateResponse(response);
+ invokeHandleSuccess(status, response);
+ } catch (FxAccountAbstractClientRemoteException e) {
+ invokeHandleFailure(e);
+ }
+ }
+
+ protected void invokeHandleFailure(final FxAccountAbstractClientRemoteException e) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.handleFailure(e);
+ }
+ });
+ }
+
+ protected void invokeHandleSuccess(final int status, final HttpResponse response) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ ExtendedJSONObject body = new SyncResponse(response).jsonObjectBody();
+ ResourceDelegate.this.handleSuccess(status, response, body);
+ } catch (Exception e) {
+ delegate.handleError(e);
+ }
+ }
+ });
+ }
+
+ @Override
+ public void handleHttpProtocolException(final ClientProtocolException e) {
+ invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public void handleHttpIOException(IOException e) {
+ invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public void handleTransportException(GeneralSecurityException e) {
+ invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+ super.addHeaders(request, client);
+
+ // The basics.
+ final Locale locale = Locale.getDefault();
+ request.addHeader(HttpHeaders.ACCEPT_LANGUAGE, Locales.getLanguageTag(locale));
+ request.addHeader(HttpHeaders.ACCEPT, ACCEPT_HEADER);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClientException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClientException.java
new file mode 100644
index 000000000..21025af0a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountAbstractClientException.java
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa.oauth;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.HttpStatus;
+
+/**
+ * From <a href="https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md">https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md</a>.
+ */
+public class FxAccountAbstractClientException extends Exception {
+ private static final long serialVersionUID = 1953459541558266597L;
+
+ public FxAccountAbstractClientException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public FxAccountAbstractClientException(Exception e) {
+ super(e);
+ }
+
+ public static class FxAccountAbstractClientRemoteException extends FxAccountAbstractClientException {
+ private static final long serialVersionUID = 1209313149952001097L;
+
+ public final HttpResponse response;
+ public final long httpStatusCode;
+ public final long apiErrorNumber;
+ public final String error;
+ public final String message;
+ public final ExtendedJSONObject body;
+
+ public FxAccountAbstractClientRemoteException(HttpResponse response, long httpStatusCode, long apiErrorNumber, String error, String message, ExtendedJSONObject body) {
+ super(new HTTPFailureException(new SyncStorageResponse(response)));
+ if (body == null) {
+ throw new IllegalArgumentException("body must not be null");
+ }
+ this.response = response;
+ this.httpStatusCode = httpStatusCode;
+ this.apiErrorNumber = apiErrorNumber;
+ this.error = error;
+ this.message = message;
+ this.body = body;
+ }
+
+ @Override
+ public String toString() {
+ return "<FxAccountAbstractClientRemoteException " + this.httpStatusCode + " [" + this.apiErrorNumber + "]: " + this.message + ">";
+ }
+
+ public boolean isInvalidAuthentication() {
+ return this.httpStatusCode == HttpStatus.SC_UNAUTHORIZED;
+ }
+ }
+
+ public static class FxAccountAbstractClientMalformedResponseException extends FxAccountAbstractClientRemoteException {
+ private static final long serialVersionUID = 1209313149952001098L;
+
+ public FxAccountAbstractClientMalformedResponseException(HttpResponse response) {
+ super(response, 0, FxAccountOAuthRemoteError.UNKNOWN_ERROR, "Response malformed", "Response malformed", new ExtendedJSONObject());
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthClient10.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthClient10.java
new file mode 100644
index 000000000..4f233695b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthClient10.java
@@ -0,0 +1,129 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa.oauth;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.concurrent.Executor;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.net.BaseResource;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+
+/**
+ * Talk to an fxa-oauth-server to get "implicitly granted" OAuth tokens.
+ * <p>
+ * To use this client, you will need a pre-allocated fxa-oauth-server
+ * "client_id" with special "implicit grant" permissions.
+ * <p>
+ * This client was written against the API documented at <a href="https://github.com/mozilla/fxa-oauth-server/blob/41538990df9e91158558ae5a8115194383ac3b05/docs/api.md">https://github.com/mozilla/fxa-oauth-server/blob/41538990df9e91158558ae5a8115194383ac3b05/docs/api.md</a>.
+ */
+public class FxAccountOAuthClient10 extends FxAccountAbstractClient {
+ protected static final String LOG_TAG = FxAccountOAuthClient10.class.getSimpleName();
+
+ protected static final String AUTHORIZATION_RESPONSE_TYPE = "token";
+
+ protected static final String JSON_KEY_ACCESS_TOKEN = "access_token";
+ protected static final String JSON_KEY_ASSERTION = "assertion";
+ protected static final String JSON_KEY_CLIENT_ID = "client_id";
+ protected static final String JSON_KEY_RESPONSE_TYPE = "response_type";
+ protected static final String JSON_KEY_SCOPE = "scope";
+ protected static final String JSON_KEY_STATE = "state";
+ protected static final String JSON_KEY_TOKEN = "token";
+ protected static final String JSON_KEY_TOKEN_TYPE = "token_type";
+
+ // access_token: A string that can be used for authorized requests to service providers.
+ // scope: A string of space-separated permissions that this token has. May differ from requested scopes, since user can deny permissions.
+ // token_type: A string representing the token type. Currently will always be "bearer".
+ protected static final String[] AUTHORIZATION_RESPONSE_REQUIRED_STRING_FIELDS = new String[] { JSON_KEY_ACCESS_TOKEN, JSON_KEY_SCOPE, JSON_KEY_TOKEN_TYPE };
+
+ public FxAccountOAuthClient10(String serverURI, Executor executor) {
+ super(serverURI, executor);
+ }
+
+ /**
+ * Thin container for an authorization response.
+ */
+ public static class AuthorizationResponse {
+ public final String access_token;
+ public final String token_type;
+ public final String scope;
+
+ public AuthorizationResponse(String access_token, String token_type, String scope) {
+ this.access_token = access_token;
+ this.token_type = token_type;
+ this.scope = scope;
+ }
+ }
+
+ public void authorization(String client_id, String assertion, String state, String scope,
+ RequestDelegate<AuthorizationResponse> delegate) {
+ final BaseResource resource;
+ try {
+ resource = new BaseResource(new URI(serverURI + "authorization"));
+ } catch (URISyntaxException e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<AuthorizationResponse>(resource, delegate) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+ try {
+ body.throwIfFieldsMissingOrMisTyped(AUTHORIZATION_RESPONSE_REQUIRED_STRING_FIELDS, String.class);
+ String access_token = body.getString(JSON_KEY_ACCESS_TOKEN);
+ String token_type = body.getString(JSON_KEY_TOKEN_TYPE);
+ String scope = body.getString(JSON_KEY_SCOPE);
+ delegate.handleSuccess(new AuthorizationResponse(access_token, token_type, scope));
+ return;
+ } catch (Exception e) {
+ delegate.handleError(e);
+ return;
+ }
+ }
+ };
+
+ final ExtendedJSONObject requestBody = new ExtendedJSONObject();
+ requestBody.put(JSON_KEY_RESPONSE_TYPE, AUTHORIZATION_RESPONSE_TYPE);
+ requestBody.put(JSON_KEY_CLIENT_ID, client_id);
+ requestBody.put(JSON_KEY_ASSERTION, assertion);
+ if (scope != null) {
+ requestBody.put(JSON_KEY_SCOPE, scope);
+ }
+ if (state != null) {
+ requestBody.put(JSON_KEY_STATE, state);
+ }
+
+ post(resource, requestBody, delegate);
+ }
+
+ public void deleteToken(final String token, final RequestDelegate<Void> delegate) {
+ final BaseResource resource;
+ try {
+ resource = new BaseResource(new URI(serverURI + "destroy"));
+ } catch (URISyntaxException e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<Void>(resource, delegate) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+ try {
+ delegate.handleSuccess(null);
+ return;
+ } catch (Exception e) {
+ delegate.handleError(e);
+ return;
+ }
+ }
+ };
+
+ final ExtendedJSONObject requestBody = new ExtendedJSONObject();
+ requestBody.put(JSON_KEY_TOKEN, token);
+ post(resource, requestBody, delegate);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthRemoteError.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthRemoteError.java
new file mode 100644
index 000000000..d949d316b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/oauth/FxAccountOAuthRemoteError.java
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa.oauth;
+
+public interface FxAccountOAuthRemoteError {
+ public static final int ATTEMPT_TO_CREATE_AN_ACCOUNT_THAT_ALREADY_EXISTS = 101;
+ public static final int UNKNOWN_CLIENT_ID = 101;
+ public static final int INCORRECT_CLIENT_SECRET = 102;
+ public static final int REDIRECT_URI_DOES_NOT_MATCH_REGISTERED_VALUE = 103;
+ public static final int INVALID_FXA_ASSERTION = 104;
+ public static final int UNKNOWN_CODE = 105;
+ public static final int INCORRECT_CODE = 106;
+ public static final int EXPIRED_CODE = 107;
+ public static final int INVALID_TOKEN = 108;
+ public static final int INVALID_REQUEST_PARAMETER = 109;
+ public static final int UNKNOWN_ERROR = 999;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/profile/FxAccountProfileClient10.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/profile/FxAccountProfileClient10.java
new file mode 100644
index 000000000..cb851a8db
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/profile/FxAccountProfileClient10.java
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.fxa.profile;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.concurrent.Executor;
+
+import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BearerAuthHeaderProvider;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+
+
+/**
+ * Talk to an fxa-profile-server to get profile information like name, age, gender, and avatar image.
+ * <p>
+ * This client was written against the API documented at <a href="https://github.com/mozilla/fxa-profile-server/blob/0c065619f5a2e867f813a343b4c67da3fe2c82a4/docs/API.md">https://github.com/mozilla/fxa-profile-server/blob/0c065619f5a2e867f813a343b4c67da3fe2c82a4/docs/API.md</a>.
+ */
+public class FxAccountProfileClient10 extends FxAccountAbstractClient {
+ public FxAccountProfileClient10(String serverURI, Executor executor) {
+ super(serverURI, executor);
+ }
+
+ public void profile(final String token, RequestDelegate<ExtendedJSONObject> delegate) {
+ BaseResource resource;
+ try {
+ resource = new BaseResource(new URI(serverURI + "profile"));
+ } catch (URISyntaxException e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<ExtendedJSONObject>(resource, delegate) {
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return new BearerAuthHeaderProvider(token);
+ }
+
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+ try {
+ delegate.handleSuccess(body);
+ return;
+ } catch (Exception e) {
+ delegate.handleError(e);
+ return;
+ }
+ }
+ };
+
+ resource.get();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/nativecode/NativeCrypto.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/nativecode/NativeCrypto.java
new file mode 100644
index 000000000..25f0f84d9
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/nativecode/NativeCrypto.java
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.nativecode;
+
+import java.security.GeneralSecurityException;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.AppConstants;
+
+import android.util.Log;
+
+@RobocopTarget
+public class NativeCrypto {
+ static {
+ try {
+ System.loadLibrary("mozglue");
+ } catch (UnsatisfiedLinkError e) {
+ Log.wtf("NativeCrypto", "Couldn't load mozglue. Trying /data/app-lib path.");
+ try {
+ System.load("/data/app-lib/" + AppConstants.ANDROID_PACKAGE_NAME + "/libmozglue.so");
+ } catch (Throwable ee) {
+ try {
+ Log.wtf("NativeCrypto", "Couldn't load mozglue: " + ee + ". Trying /data/data path.");
+ System.load("/data/data/" + AppConstants.ANDROID_PACKAGE_NAME + "/lib/libmozglue.so");
+ } catch (UnsatisfiedLinkError eee) {
+ Log.wtf("NativeCrypto", "Failed every attempt to load mozglue. Giving up.");
+ throw new RuntimeException("Unable to load mozglue", eee);
+ }
+ }
+ }
+ }
+
+ /**
+ * Wrapper to perform PBKDF2-HMAC-SHA-256 in native code.
+ */
+ public native static byte[] pbkdf2SHA256(byte[] password, byte[] salt, int c, int dkLen)
+ throws GeneralSecurityException;
+
+ /**
+ * Wrapper to perform SHA-1 in native code.
+ */
+ public native static byte[] sha1(byte[] str);
+
+ /**
+ * Wrapper to perform SHA-256 init in native code. Returns a SHA-256 context.
+ */
+ public native static byte[] sha256init();
+
+ /**
+ * Wrapper to update a SHA-256 context in native code.
+ */
+ public native static void sha256update(byte[] ctx, byte[] str, int len);
+
+ /**
+ * Wrapper to finalize a SHA-256 context in native code. Returns digest.
+ */
+ public native static byte[] sha256finalize(byte[] ctx);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceFragment.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceFragment.java
new file mode 100644
index 000000000..5bc5422c8
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceFragment.java
@@ -0,0 +1,326 @@
+/*
+ * Copyright (C) 2013 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 org.mozilla.gecko.background.preferences;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.WeakReferenceHandler;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.preference.Preference;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceManager;
+import android.preference.PreferenceScreen;
+import android.support.v4.app.Fragment;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnKeyListener;
+import android.view.ViewGroup;
+import android.widget.ListView;
+
+public abstract class PreferenceFragment extends Fragment implements PreferenceManagerCompat.OnPreferenceTreeClickListener {
+ private static final String PREFERENCES_TAG = "android:preferences";
+
+ private PreferenceManager mPreferenceManager;
+ private ListView mList;
+ private boolean mHavePrefs;
+ private boolean mInitDone;
+
+ /**
+ * The starting request code given out to preference framework.
+ */
+ private static final int FIRST_REQUEST_CODE = 100;
+
+ private static final int MSG_BIND_PREFERENCES = 1;
+
+ private static class PreferenceFragmentHandler extends WeakReferenceHandler<PreferenceFragment> {
+ public PreferenceFragmentHandler(final PreferenceFragment that) {
+ super(that);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ final PreferenceFragment that = mTarget.get();
+ if (that == null) {
+ return;
+ }
+
+ switch (msg.what) {
+
+ case MSG_BIND_PREFERENCES:
+ that.bindPreferences();
+ break;
+ }
+ }
+ }
+
+ private final Handler mHandler = new PreferenceFragmentHandler(this);
+
+ final private Runnable mRequestFocus = new Runnable() {
+ @Override
+ public void run() {
+ mList.focusableViewAvailable(mList);
+ }
+ };
+
+ /**
+ * Interface that PreferenceFragment's containing activity should
+ * implement to be able to process preference items that wish to
+ * switch to a new fragment.
+ */
+ public interface OnPreferenceStartFragmentCallback {
+ /**
+ * Called when the user has clicked on a Preference that has
+ * a fragment class name associated with it. The implementation
+ * to should instantiate and switch to an instance of the given
+ * fragment.
+ */
+ boolean onPreferenceStartFragment(PreferenceFragment caller, Preference pref);
+ }
+
+ @Override
+ public void onCreate(Bundle paramBundle) {
+ super.onCreate(paramBundle);
+ mPreferenceManager = PreferenceManagerCompat.newInstance(getActivity(), FIRST_REQUEST_CODE);
+ PreferenceManagerCompat.setFragment(mPreferenceManager, this);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater paramLayoutInflater, ViewGroup paramViewGroup, Bundle paramBundle) {
+ return paramLayoutInflater.inflate(R.layout.fxaccount_preference_list_fragment, paramViewGroup,
+ false);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ if (mHavePrefs) {
+ bindPreferences();
+ }
+
+ mInitDone = true;
+
+ if (savedInstanceState != null) {
+ Bundle container = savedInstanceState.getBundle(PREFERENCES_TAG);
+ if (container != null) {
+ final PreferenceScreen preferenceScreen = getPreferenceScreen();
+ if (preferenceScreen != null) {
+ preferenceScreen.restoreHierarchyState(container);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ PreferenceManagerCompat.setOnPreferenceTreeClickListener(mPreferenceManager, this);
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ PreferenceManagerCompat.dispatchActivityStop(mPreferenceManager);
+ PreferenceManagerCompat.setOnPreferenceTreeClickListener(mPreferenceManager, null);
+ }
+
+ @Override
+ public void onDestroyView() {
+ mList = null;
+ mHandler.removeCallbacks(mRequestFocus);
+ mHandler.removeMessages(MSG_BIND_PREFERENCES);
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ PreferenceManagerCompat.dispatchActivityDestroy(mPreferenceManager);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ final PreferenceScreen preferenceScreen = getPreferenceScreen();
+ if (preferenceScreen != null) {
+ Bundle container = new Bundle();
+ preferenceScreen.saveHierarchyState(container);
+ outState.putBundle(PREFERENCES_TAG, container);
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ PreferenceManagerCompat.dispatchActivityResult(mPreferenceManager, requestCode, resultCode, data);
+ }
+
+ /**
+ * Returns the {@link PreferenceManager} used by this fragment.
+ * @return The {@link PreferenceManager}.
+ */
+ public PreferenceManager getPreferenceManager() {
+ return mPreferenceManager;
+ }
+
+ /**
+ * Sets the root of the preference hierarchy that this fragment is showing.
+ *
+ * @param preferenceScreen The root {@link PreferenceScreen} of the preference hierarchy.
+ */
+ public void setPreferenceScreen(PreferenceScreen preferenceScreen) {
+ if (PreferenceManagerCompat.setPreferences(mPreferenceManager, preferenceScreen) && preferenceScreen != null) {
+ mHavePrefs = true;
+ if (mInitDone) {
+ postBindPreferences();
+ }
+ }
+ }
+
+ /**
+ * Gets the root of the preference hierarchy that this fragment is showing.
+ *
+ * @return The {@link PreferenceScreen} that is the root of the preference
+ * hierarchy.
+ */
+ public PreferenceScreen getPreferenceScreen() {
+ return PreferenceManagerCompat.getPreferenceScreen(mPreferenceManager);
+ }
+
+ /**
+ * Adds preferences from activities that match the given {@link Intent}.
+ *
+ * @param intent The {@link Intent} to query activities.
+ */
+ public void addPreferencesFromIntent(Intent intent) {
+ requirePreferenceManager();
+
+ setPreferenceScreen(PreferenceManagerCompat.inflateFromIntent(mPreferenceManager, intent, getPreferenceScreen()));
+ }
+
+ /**
+ * Inflates the given XML resource and adds the preference hierarchy to the current
+ * preference hierarchy.
+ *
+ * @param preferencesResId The XML resource ID to inflate.
+ */
+ public void addPreferencesFromResource(int preferencesResId) {
+ requirePreferenceManager();
+
+ setPreferenceScreen(PreferenceManagerCompat.inflateFromResource(mPreferenceManager, getActivity(),
+ preferencesResId, getPreferenceScreen()));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen,
+ Preference preference) {
+ //if (preference.getFragment() != null &&
+ if (
+ getActivity() instanceof OnPreferenceStartFragmentCallback) {
+ return ((OnPreferenceStartFragmentCallback)getActivity()).onPreferenceStartFragment(
+ this, preference);
+ }
+ return false;
+ }
+
+ /**
+ * Finds a {@link Preference} based on its key.
+ *
+ * @param key The key of the preference to retrieve.
+ * @return The {@link Preference} with the key, or null.
+ * @see PreferenceGroup#findPreference(CharSequence)
+ */
+ public Preference findPreference(CharSequence key) {
+ if (mPreferenceManager == null) {
+ return null;
+ }
+ return mPreferenceManager.findPreference(key);
+ }
+
+ private void requirePreferenceManager() {
+ if (mPreferenceManager == null) {
+ throw new RuntimeException("This should be called after super.onCreate.");
+ }
+ }
+
+ private void postBindPreferences() {
+ if (mHandler.hasMessages(MSG_BIND_PREFERENCES)) return;
+ mHandler.obtainMessage(MSG_BIND_PREFERENCES).sendToTarget();
+ }
+
+ private void bindPreferences() {
+ final PreferenceScreen preferenceScreen = getPreferenceScreen();
+ if (preferenceScreen != null) {
+ preferenceScreen.bind(getListView());
+ }
+ }
+
+ public ListView getListView() {
+ ensureList();
+ return mList;
+ }
+
+ private void ensureList() {
+ if (mList != null) {
+ return;
+ }
+ View root = getView();
+ if (root == null) {
+ throw new IllegalStateException("Content view not yet created");
+ }
+ View rawListView = root.findViewById(android.R.id.list);
+ if (!(rawListView instanceof ListView)) {
+ throw new RuntimeException(
+ "Content has view with id attribute 'android.R.id.list' "
+ + "that is not a ListView class");
+ }
+ mList = (ListView)rawListView;
+ if (mList == null) {
+ throw new RuntimeException(
+ "Your content must have a ListView whose id attribute is " +
+ "'android.R.id.list'");
+ }
+ mList.setOnKeyListener(mListOnKeyListener);
+ mHandler.post(mRequestFocus);
+ }
+
+ private final OnKeyListener mListOnKeyListener = new OnKeyListener() {
+
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ Object selectedItem = mList.getSelectedItem();
+ if (selectedItem instanceof Preference) {
+ @SuppressWarnings("unused")
+ View selectedView = mList.getSelectedView();
+ //return ((Preference)selectedItem).onKey(
+ // selectedView, keyCode, event);
+ return false;
+ }
+ return false;
+ }
+
+ };
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceManagerCompat.java b/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceManagerCompat.java
new file mode 100644
index 000000000..22c62e431
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/preferences/PreferenceManagerCompat.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2013 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 org.mozilla.gecko.background.preferences;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.preference.Preference;
+import android.preference.PreferenceManager;
+import android.preference.PreferenceScreen;
+import android.util.Log;
+
+public class PreferenceManagerCompat {
+
+ private static final String TAG = PreferenceManagerCompat.class.getSimpleName();
+
+ /**
+ * Interface definition for a callback to be invoked when a {@link Preference} in the hierarchy
+ * rooted at this {@link PreferenceScreen} is clicked.
+ */
+ interface OnPreferenceTreeClickListener {
+ /**
+ * Called when a preference in the tree rooted at this {@link PreferenceScreen} has been
+ * clicked.
+ *
+ * @param preferenceScreen The {@link PreferenceScreen} that the preference is located in.
+ * @param preference The preference that was clicked.
+ *
+ * @return Whether the click was handled.
+ */
+ boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, Preference preference);
+ }
+
+ static PreferenceManager newInstance(Activity activity, int firstRequestCode) {
+ try {
+ Constructor<PreferenceManager> c = PreferenceManager.class.getDeclaredConstructor(Activity.class, int.class);
+ c.setAccessible(true);
+ return c.newInstance(activity, firstRequestCode);
+ } catch (Exception e) {
+ Log.w(TAG, "Couldn't call constructor PreferenceManager by reflection", e);
+ }
+ return null;
+ }
+
+ /**
+ * Sets the owning preference fragment
+ */
+ static void setFragment(PreferenceManager manager, PreferenceFragment fragment) {
+ // stub
+ }
+
+ /**
+ * Sets the callback to be invoked when a {@link Preference} in the hierarchy rooted at this
+ * {@link PreferenceManager} is clicked.
+ *
+ * @param listener The callback to be invoked.
+ */
+ static void setOnPreferenceTreeClickListener(PreferenceManager manager, final OnPreferenceTreeClickListener listener) {
+ try {
+ Field onPreferenceTreeClickListener = PreferenceManager.class.getDeclaredField("mOnPreferenceTreeClickListener");
+ onPreferenceTreeClickListener.setAccessible(true);
+ if (listener != null) {
+ Object proxy = Proxy.newProxyInstance(
+ onPreferenceTreeClickListener.getType().getClassLoader(),
+ new Class<?>[] { onPreferenceTreeClickListener.getType() },
+ new InvocationHandler() {
+ @Override
+ public Object invoke(Object proxy, Method method, Object[] args) {
+ if (method.getName().equals("onPreferenceTreeClick")) {
+ return listener.onPreferenceTreeClick((PreferenceScreen) args[0], (Preference) args[1]);
+ } else {
+ return null;
+ }
+ }
+ });
+ onPreferenceTreeClickListener.set(manager, proxy);
+ } else {
+ onPreferenceTreeClickListener.set(manager, null);
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Couldn't set PreferenceManager.mOnPreferenceTreeClickListener by reflection", e);
+ }
+ }
+
+ /**
+ * Inflates a preference hierarchy from the preference hierarchies of {@link Activity Activities}
+ * that match the given {@link Intent}. An {@link Activity} defines its preference hierarchy with
+ * meta-data using the {@link #METADATA_KEY_PREFERENCES} key.
+ * <p/>
+ * If a preference hierarchy is given, the new preference hierarchies will be merged in.
+ *
+ * @param queryIntent The intent to match activities.
+ * @param rootPreferences Optional existing hierarchy to merge the new hierarchies into.
+ *
+ * @return The root hierarchy (if one was not provided, the new hierarchy's root).
+ */
+ static PreferenceScreen inflateFromIntent(PreferenceManager manager, Intent intent, PreferenceScreen screen) {
+ try {
+ Method m = PreferenceManager.class.getDeclaredMethod("inflateFromIntent", Intent.class, PreferenceScreen.class);
+ m.setAccessible(true);
+ PreferenceScreen prefScreen = (PreferenceScreen) m.invoke(manager, intent, screen);
+ return prefScreen;
+ } catch (Exception e) {
+ Log.w(TAG, "Couldn't call PreferenceManager.inflateFromIntent by reflection", e);
+ }
+ return null;
+ }
+
+ /**
+ * Inflates a preference hierarchy from XML. If a preference hierarchy is given, the new
+ * preference hierarchies will be merged in.
+ *
+ * @param context The context of the resource.
+ * @param resId The resource ID of the XML to inflate.
+ * @param rootPreferences Optional existing hierarchy to merge the new hierarchies into.
+ *
+ * @return The root hierarchy (if one was not provided, the new hierarchy's root).
+ *
+ * @hide
+ */
+ static PreferenceScreen inflateFromResource(PreferenceManager manager, Activity activity, int resId, PreferenceScreen screen) {
+ try {
+ Method m = PreferenceManager.class.getDeclaredMethod("inflateFromResource", Context.class, int.class, PreferenceScreen.class);
+ m.setAccessible(true);
+ PreferenceScreen prefScreen = (PreferenceScreen) m.invoke(manager, activity, resId, screen);
+ return prefScreen;
+ } catch (Exception e) {
+ Log.w(TAG, "Couldn't call PreferenceManager.inflateFromResource by reflection", e);
+ }
+ return null;
+ }
+
+ /**
+ * Returns the root of the preference hierarchy managed by this class.
+ *
+ * @return The {@link PreferenceScreen} object that is at the root of the hierarchy.
+ */
+ static PreferenceScreen getPreferenceScreen(PreferenceManager manager) {
+ try {
+ Method m = PreferenceManager.class.getDeclaredMethod("getPreferenceScreen");
+ m.setAccessible(true);
+ return (PreferenceScreen) m.invoke(manager);
+ } catch (Exception e) {
+ Log.w(TAG, "Couldn't call PreferenceManager.getPreferenceScreen by reflection", e);
+ }
+ return null;
+ }
+
+ /**
+ * Called by the {@link PreferenceManager} to dispatch a subactivity result.
+ */
+ static void dispatchActivityResult(PreferenceManager manager, int requestCode, int resultCode, Intent data) {
+ try {
+ Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityResult", int.class, int.class, Intent.class);
+ m.setAccessible(true);
+ m.invoke(manager, requestCode, resultCode, data);
+ } catch (Exception e) {
+ Log.w(TAG, "Couldn't call PreferenceManager.dispatchActivityResult by reflection", e);
+ }
+ }
+
+ /**
+ * Called by the {@link PreferenceManager} to dispatch the activity stop event.
+ */
+ static void dispatchActivityStop(PreferenceManager manager) {
+ try {
+ Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityStop");
+ m.setAccessible(true);
+ m.invoke(manager);
+ } catch (Exception e) {
+ Log.w(TAG, "Couldn't call PreferenceManager.dispatchActivityStop by reflection", e);
+ }
+ }
+
+ /**
+ * Called by the {@link PreferenceManager} to dispatch the activity destroy event.
+ */
+ static void dispatchActivityDestroy(PreferenceManager manager) {
+ try {
+ Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityDestroy");
+ m.setAccessible(true);
+ m.invoke(manager);
+ } catch (Exception e) {
+ Log.w(TAG, "Couldn't call PreferenceManager.dispatchActivityDestroy by reflection", e);
+ }
+ }
+
+ /**
+ * Sets the root of the preference hierarchy.
+ *
+ * @param preferenceScreen The root {@link PreferenceScreen} of the preference hierarchy.
+ *
+ * @return Whether the {@link PreferenceScreen} given is different than the previous.
+ */
+ static boolean setPreferences(PreferenceManager manager, PreferenceScreen screen) {
+ try {
+ Method m = PreferenceManager.class.getDeclaredMethod("setPreferences", PreferenceScreen.class);
+ m.setAccessible(true);
+ return ((Boolean) m.invoke(manager, screen));
+ } catch (Exception e) {
+ Log.w(TAG, "Couldn't call PreferenceManager.setPreferences by reflection", e);
+ }
+ return false;
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/ASNUtils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/ASNUtils.java
new file mode 100644
index 000000000..b032067c5
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/ASNUtils.java
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid;
+
+
+/**
+ * Java produces signature in ASN.1 format. Here's some hard-coded encoding and decoding
+ * code, courtesy of a comment in
+ * <a href="http://stackoverflow.com/questions/10921733/how-sign-method-of-the-digital-signature-combines-the-r-s-values-in-to-array">http://stackoverflow.com/questions/10921733/how-sign-method-of-the-digital-signature-combines-the-r-s-values-in-to-array</a>.
+ */
+public class ASNUtils {
+ /**
+ * Decode two short arrays from ASN.1 bytes.
+ * @param input to extract.
+ * @return length 2 array of byte arrays.
+ */
+ public static byte[][] decodeTwoArraysFromASN1(byte[] input) throws IllegalArgumentException {
+ if (input == null) {
+ throw new IllegalArgumentException("input must not be null");
+ }
+ if (input.length <= 3)
+ throw new IllegalArgumentException("bad length");
+ if (input[0] != 0x30)
+ throw new IllegalArgumentException("bad encoding");
+ if ((input[1] & ((byte) 0x80)) != 0)
+ throw new IllegalArgumentException("bad length encoding");
+ if (input[2] != 0x02)
+ throw new IllegalArgumentException("bad encoding");
+ if ((input[3] & ((byte) 0x80)) != 0)
+ throw new IllegalArgumentException("bad length encoding");
+ byte rLength = input[3];
+ if (input.length <= 5 + rLength)
+ throw new IllegalArgumentException("bad length");
+ if (input[4 + rLength] != 0x02)
+ throw new IllegalArgumentException("bad encoding");
+ if ((input[5 + rLength] & (byte) 0x80) !=0)
+ throw new IllegalArgumentException("bad length encoding");
+ byte sLength = input[5 + rLength];
+ if (input.length != 6 + sLength + rLength)
+ throw new IllegalArgumentException("bad length");
+ byte[] rArr = new byte[rLength];
+ byte[] sArr = new byte[sLength];
+ System.arraycopy(input, 4, rArr, 0, rLength);
+ System.arraycopy(input, 6 + rLength, sArr, 0, sLength);
+ return new byte[][] { rArr, sArr };
+ }
+
+ /**
+ * Encode two short arrays into ASN.1 bytes.
+ * @param first array to encode.
+ * @param second array to encode.
+ * @return array.
+ */
+ public static byte[] encodeTwoArraysToASN1(byte[] first, byte[] second) throws IllegalArgumentException {
+ if (first == null) {
+ throw new IllegalArgumentException("first must not be null");
+ }
+ if (second == null) {
+ throw new IllegalArgumentException("second must not be null");
+ }
+ byte[] output = new byte[6 + first.length + second.length];
+ output[0] = 0x30;
+ if (4 + first.length + second.length > 255)
+ throw new IllegalArgumentException("bad length");
+ output[1] = (byte) (4 + first.length + second.length);
+ if ((output[1] & ((byte) 0x80)) != 0)
+ throw new IllegalArgumentException("bad length encoding");
+ output[2] = 0x02;
+ output[3] = (byte) first.length;
+ if ((output[3] & ((byte) 0x80)) != 0)
+ throw new IllegalArgumentException("bad length encoding");
+ System.arraycopy(first, 0, output, 4, first.length);
+ output[4 + first.length] = 0x02;
+ output[5 + first.length] = (byte) second.length;
+ if ((output[5 + first.length] & ((byte) 0x80)) != 0)
+ throw new IllegalArgumentException("bad length encoding");
+ System.arraycopy(second, 0, output, 6 + first.length, second.length);
+ return output;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/BrowserIDKeyPair.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/BrowserIDKeyPair.java
new file mode 100644
index 000000000..7283a0299
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/BrowserIDKeyPair.java
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+public class BrowserIDKeyPair {
+ public static final String JSON_KEY_PRIVATEKEY = "privateKey";
+ public static final String JSON_KEY_PUBLICKEY = "publicKey";
+
+ protected final SigningPrivateKey privateKey;
+ protected final VerifyingPublicKey publicKey;
+
+ public BrowserIDKeyPair(SigningPrivateKey privateKey, VerifyingPublicKey publicKey) {
+ this.privateKey = privateKey;
+ this.publicKey = publicKey;
+ }
+
+ public SigningPrivateKey getPrivate() {
+ return this.privateKey;
+ }
+
+ public VerifyingPublicKey getPublic() {
+ return this.publicKey;
+ }
+
+ public ExtendedJSONObject toJSONObject() {
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put(JSON_KEY_PRIVATEKEY, privateKey.toJSONObject());
+ o.put(JSON_KEY_PUBLICKEY, publicKey.toJSONObject());
+ return o;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/DSACryptoImplementation.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/DSACryptoImplementation.java
new file mode 100644
index 000000000..a04a89c8e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/DSACryptoImplementation.java
@@ -0,0 +1,255 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid;
+
+import android.annotation.SuppressLint;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.PRNGFixes;
+
+import java.math.BigInteger;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.Signature;
+import java.security.interfaces.DSAParams;
+import java.security.interfaces.DSAPrivateKey;
+import java.security.interfaces.DSAPublicKey;
+import java.security.spec.DSAPrivateKeySpec;
+import java.security.spec.DSAPublicKeySpec;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+
+public class DSACryptoImplementation {
+ private static final String LOG_TAG = DSACryptoImplementation.class.getSimpleName();
+
+ public static final String SIGNATURE_ALGORITHM = "SHA1withDSA";
+ public static final int SIGNATURE_LENGTH_BYTES = 40; // DSA signatures are always 40 bytes long.
+
+ /**
+ * Parameters are serialized as hex strings. Hex-versus-decimal was
+ * reverse-engineered from what the Persona public verifier accepted. We
+ * expect to follow the JOSE/JWT spec as it solidifies, and that will probably
+ * mean unifying this base.
+ */
+ protected static final int SERIALIZATION_BASE = 16;
+
+ protected static class DSAVerifyingPublicKey implements VerifyingPublicKey {
+ protected final DSAPublicKey publicKey;
+
+ public DSAVerifyingPublicKey(DSAPublicKey publicKey) {
+ this.publicKey = publicKey;
+ }
+
+ /**
+ * Serialize to a JSON object.
+ * <p>
+ * Parameters are serialized as hex strings. Hex-versus-decimal was
+ * reverse-engineered from what the Persona public verifier accepted.
+ */
+ @Override
+ public ExtendedJSONObject toJSONObject() {
+ DSAParams params = publicKey.getParams();
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put("algorithm", "DS");
+ o.put("y", publicKey.getY().toString(SERIALIZATION_BASE));
+ o.put("g", params.getG().toString(SERIALIZATION_BASE));
+ o.put("p", params.getP().toString(SERIALIZATION_BASE));
+ o.put("q", params.getQ().toString(SERIALIZATION_BASE));
+ return o;
+ }
+
+ @Override
+ public boolean verifyMessage(byte[] bytes, byte[] signature)
+ throws GeneralSecurityException {
+ if (bytes == null) {
+ throw new IllegalArgumentException("bytes must not be null");
+ }
+ if (signature == null) {
+ throw new IllegalArgumentException("signature must not be null");
+ }
+ if (signature.length != SIGNATURE_LENGTH_BYTES) {
+ return false;
+ }
+ byte[] first = new byte[signature.length / 2];
+ byte[] second = new byte[signature.length / 2];
+ System.arraycopy(signature, 0, first, 0, first.length);
+ System.arraycopy(signature, first.length, second, 0, second.length);
+ BigInteger r = new BigInteger(Utils.byte2Hex(first), 16);
+ BigInteger s = new BigInteger(Utils.byte2Hex(second), 16);
+ // This is awful, but encoding an extra 0 byte works better on devices.
+ byte[] encoded = ASNUtils.encodeTwoArraysToASN1(
+ Utils.hex2Byte(r.toString(16), 1 + SIGNATURE_LENGTH_BYTES / 2),
+ Utils.hex2Byte(s.toString(16), 1 + SIGNATURE_LENGTH_BYTES / 2));
+
+ final Signature signer = Signature.getInstance(SIGNATURE_ALGORITHM);
+ signer.initVerify(publicKey);
+ signer.update(bytes);
+ return signer.verify(encoded);
+ }
+ }
+
+ protected static class DSASigningPrivateKey implements SigningPrivateKey {
+ protected final DSAPrivateKey privateKey;
+
+ public DSASigningPrivateKey(DSAPrivateKey privateKey) {
+ this.privateKey = privateKey;
+ }
+
+ @Override
+ public String getAlgorithm() {
+ return "DS" + (privateKey.getParams().getP().bitLength() + 7)/8;
+ }
+
+ /**
+ * Serialize to a JSON object.
+ * <p>
+ * Parameters are serialized as decimal strings. Hex-versus-decimal was
+ * reverse-engineered from what the Persona public verifier accepted.
+ */
+ @Override
+ public ExtendedJSONObject toJSONObject() {
+ DSAParams params = privateKey.getParams();
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put("algorithm", "DS");
+ o.put("x", privateKey.getX().toString(SERIALIZATION_BASE));
+ o.put("g", params.getG().toString(SERIALIZATION_BASE));
+ o.put("p", params.getP().toString(SERIALIZATION_BASE));
+ o.put("q", params.getQ().toString(SERIALIZATION_BASE));
+ return o;
+ }
+
+ @SuppressLint("TrulyRandom")
+ @Override
+ public byte[] signMessage(byte[] bytes)
+ throws GeneralSecurityException {
+ if (bytes == null) {
+ throw new IllegalArgumentException("bytes must not be null");
+ }
+
+ try {
+ PRNGFixes.apply();
+ } catch (Exception e) {
+ // Not much to be done here: it was weak before, and we couldn't patch it, so it's weak now. Not worth aborting.
+ Logger.error(LOG_TAG, "Got exception applying PRNGFixes! Cryptographic data produced on this device may be weak. Ignoring.", e);
+ }
+
+ final Signature signer = Signature.getInstance(SIGNATURE_ALGORITHM);
+ signer.initSign(privateKey);
+ signer.update(bytes);
+ final byte[] signature = signer.sign();
+
+ final byte[][] arrays = ASNUtils.decodeTwoArraysFromASN1(signature);
+ BigInteger r = new BigInteger(arrays[0]);
+ BigInteger s = new BigInteger(arrays[1]);
+ // This is awful, but signatures are always 40 bytes long.
+ byte[] decoded = Utils.concatAll(
+ Utils.hex2Byte(r.toString(16), SIGNATURE_LENGTH_BYTES / 2),
+ Utils.hex2Byte(s.toString(16), SIGNATURE_LENGTH_BYTES / 2));
+ return decoded;
+ }
+ }
+
+ public static BrowserIDKeyPair generateKeyPair(int keysize)
+ throws NoSuchAlgorithmException {
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("DSA");
+ keyPairGenerator.initialize(keysize);
+ final KeyPair keyPair = keyPairGenerator.generateKeyPair();
+ DSAPrivateKey privateKey = (DSAPrivateKey) keyPair.getPrivate();
+ DSAPublicKey publicKey = (DSAPublicKey) keyPair.getPublic();
+ return new BrowserIDKeyPair(new DSASigningPrivateKey(privateKey), new DSAVerifyingPublicKey(publicKey));
+ }
+
+ public static SigningPrivateKey createPrivateKey(BigInteger x, BigInteger p, BigInteger q, BigInteger g) throws NoSuchAlgorithmException, InvalidKeySpecException {
+ if (x == null) {
+ throw new IllegalArgumentException("x must not be null");
+ }
+ if (p == null) {
+ throw new IllegalArgumentException("p must not be null");
+ }
+ if (q == null) {
+ throw new IllegalArgumentException("q must not be null");
+ }
+ if (g == null) {
+ throw new IllegalArgumentException("g must not be null");
+ }
+ KeySpec keySpec = new DSAPrivateKeySpec(x, p, q, g);
+ KeyFactory keyFactory = KeyFactory.getInstance("DSA");
+ DSAPrivateKey privateKey = (DSAPrivateKey) keyFactory.generatePrivate(keySpec);
+ return new DSASigningPrivateKey(privateKey);
+ }
+
+ public static VerifyingPublicKey createPublicKey(BigInteger y, BigInteger p, BigInteger q, BigInteger g) throws NoSuchAlgorithmException, InvalidKeySpecException {
+ if (y == null) {
+ throw new IllegalArgumentException("n must not be null");
+ }
+ if (p == null) {
+ throw new IllegalArgumentException("p must not be null");
+ }
+ if (q == null) {
+ throw new IllegalArgumentException("q must not be null");
+ }
+ if (g == null) {
+ throw new IllegalArgumentException("g must not be null");
+ }
+ KeySpec keySpec = new DSAPublicKeySpec(y, p, q, g);
+ KeyFactory keyFactory = KeyFactory.getInstance("DSA");
+ DSAPublicKey publicKey = (DSAPublicKey) keyFactory.generatePublic(keySpec);
+ return new DSAVerifyingPublicKey(publicKey);
+ }
+
+ public static SigningPrivateKey createPrivateKey(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException {
+ String algorithm = o.getString("algorithm");
+ if (!"DS".equals(algorithm)) {
+ throw new InvalidKeySpecException("algorithm must equal DS, was " + algorithm);
+ }
+ try {
+ BigInteger x = new BigInteger(o.getString("x"), SERIALIZATION_BASE);
+ BigInteger p = new BigInteger(o.getString("p"), SERIALIZATION_BASE);
+ BigInteger q = new BigInteger(o.getString("q"), SERIALIZATION_BASE);
+ BigInteger g = new BigInteger(o.getString("g"), SERIALIZATION_BASE);
+ return createPrivateKey(x, p, q, g);
+ } catch (NullPointerException | NumberFormatException e) {
+ throw new InvalidKeySpecException("x, p, q, and g must be integers encoded as strings, base " + SERIALIZATION_BASE);
+ }
+ }
+
+ public static VerifyingPublicKey createPublicKey(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException {
+ String algorithm = o.getString("algorithm");
+ if (!"DS".equals(algorithm)) {
+ throw new InvalidKeySpecException("algorithm must equal DS, was " + algorithm);
+ }
+ try {
+ BigInteger y = new BigInteger(o.getString("y"), SERIALIZATION_BASE);
+ BigInteger p = new BigInteger(o.getString("p"), SERIALIZATION_BASE);
+ BigInteger q = new BigInteger(o.getString("q"), SERIALIZATION_BASE);
+ BigInteger g = new BigInteger(o.getString("g"), SERIALIZATION_BASE);
+ return createPublicKey(y, p, q, g);
+ } catch (NullPointerException | NumberFormatException e) {
+ throw new InvalidKeySpecException("y, p, q, and g must be integers encoded as strings, base " + SERIALIZATION_BASE);
+ }
+ }
+
+ public static BrowserIDKeyPair fromJSONObject(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException {
+ try {
+ ExtendedJSONObject privateKey = o.getObject(BrowserIDKeyPair.JSON_KEY_PRIVATEKEY);
+ ExtendedJSONObject publicKey = o.getObject(BrowserIDKeyPair.JSON_KEY_PUBLICKEY);
+ if (privateKey == null) {
+ throw new InvalidKeySpecException("privateKey must not be null");
+ }
+ if (publicKey == null) {
+ throw new InvalidKeySpecException("publicKey must not be null");
+ }
+ return new BrowserIDKeyPair(createPrivateKey(privateKey), createPublicKey(publicKey));
+ } catch (NonObjectJSONException e) {
+ throw new InvalidKeySpecException("privateKey and publicKey must be JSON objects");
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/JSONWebTokenUtils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/JSONWebTokenUtils.java
new file mode 100644
index 000000000..207accc76
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/JSONWebTokenUtils.java
@@ -0,0 +1,245 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid;
+
+import org.json.simple.JSONObject;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.apache.commons.codec.binary.StringUtils;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.Utils;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.security.GeneralSecurityException;
+import java.util.ArrayList;
+import java.util.TreeMap;
+
+/**
+ * Encode and decode JSON Web Tokens.
+ * <p>
+ * Reverse-engineered from the Node.js jwcrypto library at
+ * <a href="https://github.com/mozilla/jwcrypto">https://github.com/mozilla/jwcrypto</a>
+ * and informed by the informal draft standard "JSON Web Token (JWT)" at
+ * <a href="http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html">http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html</a>.
+ */
+public class JSONWebTokenUtils {
+ public static final long DEFAULT_CERTIFICATE_DURATION_IN_MILLISECONDS = 60 * 60 * 1000;
+ public static final long DEFAULT_ASSERTION_DURATION_IN_MILLISECONDS = 60 * 60 * 1000;
+ public static final long DEFAULT_FUTURE_EXPIRES_AT_IN_MILLISECONDS = 9999999999999L;
+ public static final String DEFAULT_CERTIFICATE_ISSUER = "127.0.0.1";
+ public static final String DEFAULT_ASSERTION_ISSUER = "127.0.0.1";
+
+ public static String encode(String payload, SigningPrivateKey privateKey) throws UnsupportedEncodingException, GeneralSecurityException {
+ final ExtendedJSONObject header = new ExtendedJSONObject();
+ header.put("alg", privateKey.getAlgorithm());
+ String encodedHeader = Base64.encodeBase64URLSafeString(header.toJSONString().getBytes("UTF-8"));
+ String encodedPayload = Base64.encodeBase64URLSafeString(payload.getBytes("UTF-8"));
+ ArrayList<String> segments = new ArrayList<String>();
+ segments.add(encodedHeader);
+ segments.add(encodedPayload);
+ byte[] message = Utils.toDelimitedString(".", segments).getBytes("UTF-8");
+ byte[] signature = privateKey.signMessage(message);
+ segments.add(Base64.encodeBase64URLSafeString(signature));
+ return Utils.toDelimitedString(".", segments);
+ }
+
+ public static String decode(String token, VerifyingPublicKey publicKey) throws GeneralSecurityException, UnsupportedEncodingException {
+ if (token == null) {
+ throw new IllegalArgumentException("token must not be null");
+ }
+ String[] segments = token.split("\\.");
+ if (segments == null || segments.length != 3) {
+ throw new GeneralSecurityException("malformed token");
+ }
+ byte[] message = (segments[0] + "." + segments[1]).getBytes("UTF-8");
+ byte[] signature = Base64.decodeBase64(segments[2]);
+ boolean verifies = publicKey.verifyMessage(message, signature);
+ if (!verifies) {
+ throw new GeneralSecurityException("bad signature");
+ }
+ String payload = StringUtils.newStringUtf8(Base64.decodeBase64(segments[1]));
+ return payload;
+ }
+
+ /**
+ * Public for testing.
+ */
+ @SuppressWarnings("unchecked")
+ public static String getPayloadString(String payloadString, String audience, String issuer,
+ Long issuedAt, long expiresAt) throws NonObjectJSONException, IOException {
+ ExtendedJSONObject payload;
+ if (payloadString != null) {
+ payload = new ExtendedJSONObject(payloadString);
+ } else {
+ payload = new ExtendedJSONObject();
+ }
+ if (audience != null) {
+ payload.put("aud", audience);
+ }
+ payload.put("iss", issuer);
+ if (issuedAt != null) {
+ payload.put("iat", issuedAt);
+ }
+ payload.put("exp", expiresAt);
+ // TreeMap so that keys are sorted. A small attempt to keep output stable over time.
+ return JSONObject.toJSONString(new TreeMap<Object, Object>(payload.object));
+ }
+
+ protected static String getCertificatePayloadString(VerifyingPublicKey publicKeyToSign, String email) throws NonObjectJSONException, IOException {
+ ExtendedJSONObject payload = new ExtendedJSONObject();
+ ExtendedJSONObject principal = new ExtendedJSONObject();
+ principal.put("email", email);
+ payload.put("principal", principal);
+ payload.put("public-key", publicKeyToSign.toJSONObject());
+ return payload.toJSONString();
+ }
+
+ public static String createCertificate(VerifyingPublicKey publicKeyToSign, String email,
+ String issuer, long issuedAt, long expiresAt, SigningPrivateKey privateKey) throws NonObjectJSONException, IOException, GeneralSecurityException {
+ String certificatePayloadString = getCertificatePayloadString(publicKeyToSign, email);
+ String payloadString = getPayloadString(certificatePayloadString, null, issuer, issuedAt, expiresAt);
+ return JSONWebTokenUtils.encode(payloadString, privateKey);
+ }
+
+ /**
+ * Create a Browser ID assertion.
+ *
+ * @param privateKeyToSignWith
+ * private key to sign assertion with.
+ * @param certificate
+ * to include in assertion; no attempt is made to ensure the
+ * certificate is valid, or corresponds to the private key, or any
+ * other condition.
+ * @param audience
+ * to produce assertion for.
+ * @param issuer
+ * to produce assertion for.
+ * @param issuedAt
+ * timestamp for assertion, in milliseconds since the epoch; if null,
+ * no timestamp is included.
+ * @param expiresAt
+ * expiration timestamp for assertion, in milliseconds since the epoch.
+ * @return assertion.
+ * @throws NonObjectJSONException
+ * @throws IOException
+ * @throws GeneralSecurityException
+ */
+ public static String createAssertion(SigningPrivateKey privateKeyToSignWith, String certificate, String audience,
+ String issuer, Long issuedAt, long expiresAt) throws NonObjectJSONException, IOException, GeneralSecurityException {
+ String emptyAssertionPayloadString = "{}";
+ String payloadString = getPayloadString(emptyAssertionPayloadString, audience, issuer, issuedAt, expiresAt);
+ String signature = JSONWebTokenUtils.encode(payloadString, privateKeyToSignWith);
+ return certificate + "~" + signature;
+ }
+
+ /**
+ * For debugging only!
+ *
+ * @param input
+ * certificate to dump.
+ * @return non-null object with keys header, payload, signature if the
+ * certificate is well-formed.
+ */
+ public static ExtendedJSONObject parseCertificate(String input) {
+ try {
+ String[] parts = input.split("\\.");
+ if (parts.length != 3) {
+ return null;
+ }
+ String cHeader = new String(Base64.decodeBase64(parts[0]));
+ String cPayload = new String(Base64.decodeBase64(parts[1]));
+ String cSignature = Utils.byte2Hex(Base64.decodeBase64(parts[2]));
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put("header", new ExtendedJSONObject(cHeader));
+ o.put("payload", new ExtendedJSONObject(cPayload));
+ o.put("signature", cSignature);
+ return o;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * For debugging only!
+ *
+ * @param input certificate to dump.
+ * @return true if the certificate is well-formed.
+ */
+ public static boolean dumpCertificate(String input) {
+ ExtendedJSONObject c = parseCertificate(input);
+ try {
+ if (c == null) {
+ System.out.println("Malformed certificate -- got exception trying to dump contents.");
+ return false;
+ }
+ System.out.println("certificate header: " + c.getObject("header").toJSONString());
+ System.out.println("certificate payload: " + c.getObject("payload").toJSONString());
+ System.out.println("certificate signature: " + c.getString("signature"));
+ return true;
+ } catch (Exception e) {
+ System.out.println("Malformed certificate -- got exception trying to dump contents.");
+ return false;
+ }
+ }
+
+ /**
+ * For debugging only!
+ *
+ * @param input assertion to dump.
+ * @return true if the assertion is well-formed.
+ */
+ public static ExtendedJSONObject parseAssertion(String input) {
+ try {
+ String[] parts = input.split("~");
+ if (parts.length != 2) {
+ return null;
+ }
+ String certificate = parts[0];
+ String assertion = parts[1];
+ parts = assertion.split("\\.");
+ if (parts.length != 3) {
+ return null;
+ }
+ String aHeader = new String(Base64.decodeBase64(parts[0]));
+ String aPayload = new String(Base64.decodeBase64(parts[1]));
+ String aSignature = Utils.byte2Hex(Base64.decodeBase64(parts[2]));
+ // We do all the assertion parsing *before* dumping the certificate in
+ // case there's a malformed assertion.
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put("header", new ExtendedJSONObject(aHeader));
+ o.put("payload", new ExtendedJSONObject(aPayload));
+ o.put("signature", aSignature);
+ o.put("certificate", certificate);
+ return o;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * For debugging only!
+ *
+ * @param input assertion to dump.
+ * @return true if the assertion is well-formed.
+ */
+ public static boolean dumpAssertion(String input) {
+ ExtendedJSONObject a = parseAssertion(input);
+ try {
+ if (a == null) {
+ System.out.println("Malformed assertion -- got exception trying to dump contents.");
+ return false;
+ }
+ dumpCertificate(a.getString("certificate"));
+ System.out.println("assertion header: " + a.getObject("header").toJSONString());
+ System.out.println("assertion payload: " + a.getObject("payload").toJSONString());
+ System.out.println("assertion signature: " + a.getString("signature"));
+ return true;
+ } catch (Exception e) {
+ System.out.println("Malformed assertion -- got exception trying to dump contents.");
+ return false;
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/MockMyIDTokenFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/MockMyIDTokenFactory.java
new file mode 100644
index 000000000..c807d4cbb
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/MockMyIDTokenFactory.java
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid;
+
+import java.math.BigInteger;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+
+/**
+ * Generate certificates and assertions backed by mockmyid.com's private key.
+ * <p>
+ * These artifacts are for testing only.
+ */
+public class MockMyIDTokenFactory {
+ public static final BigInteger MOCKMYID_x = new BigInteger("385cb3509f086e110c5e24bdd395a84b335a09ae", 16);
+ public static final BigInteger MOCKMYID_y = new BigInteger("738ec929b559b604a232a9b55a5295afc368063bb9c20fac4e53a74970a4db7956d48e4c7ed523405f629b4cc83062f13029c4d615bbacb8b97f5e56f0c7ac9bc1d4e23809889fa061425c984061fca1826040c399715ce7ed385c4dd0d402256912451e03452d3c961614eb458f188e3e8d2782916c43dbe2e571251ce38262", 16);
+ public static final BigInteger MOCKMYID_p = new BigInteger("ff600483db6abfc5b45eab78594b3533d550d9f1bf2a992a7a8daa6dc34f8045ad4e6e0c429d334eeeaaefd7e23d4810be00e4cc1492cba325ba81ff2d5a5b305a8d17eb3bf4a06a349d392e00d329744a5179380344e82a18c47933438f891e22aeef812d69c8f75e326cb70ea000c3f776dfdbd604638c2ef717fc26d02e17", 16);
+ public static final BigInteger MOCKMYID_q = new BigInteger("e21e04f911d1ed7991008ecaab3bf775984309c3", 16);
+ public static final BigInteger MOCKMYID_g = new BigInteger("c52a4a0ff3b7e61fdf1867ce84138369a6154f4afa92966e3c827e25cfa6cf508b90e5de419e1337e07a2e9e2a3cd5dea704d175f8ebf6af397d69e110b96afb17c7a03259329e4829b0d03bbc7896b15b4ade53e130858cc34d96269aa89041f409136c7242a38895c9d5bccad4f389af1d7a4bd1398bd072dffa896233397a", 16);
+
+ // Computed lazily by static <code>getMockMyIDPrivateKey</code>.
+ protected static SigningPrivateKey cachedMockMyIDPrivateKey;
+
+ public static SigningPrivateKey getMockMyIDPrivateKey() throws NoSuchAlgorithmException, InvalidKeySpecException {
+ if (cachedMockMyIDPrivateKey == null) {
+ cachedMockMyIDPrivateKey = DSACryptoImplementation.createPrivateKey(MOCKMYID_x, MOCKMYID_p, MOCKMYID_q, MOCKMYID_g);
+ }
+ return cachedMockMyIDPrivateKey;
+ }
+
+ /**
+ * Sign a public key asserting ownership of username@mockmyid.com with
+ * mockmyid.com's private key.
+ *
+ * @param publicKeyToSign
+ * public key to sign.
+ * @param username
+ * sign username@mockmyid.com
+ * @param issuedAt
+ * timestamp for certificate, in milliseconds since the epoch.
+ * @param expiresAt
+ * expiration timestamp for certificate, in milliseconds since the epoch.
+ * @return encoded certificate string.
+ * @throws Exception
+ */
+ public String createMockMyIDCertificate(final VerifyingPublicKey publicKeyToSign, String username,
+ final long issuedAt, final long expiresAt)
+ throws Exception {
+ if (!username.endsWith("@mockmyid.com")) {
+ username = username + "@mockmyid.com";
+ }
+ SigningPrivateKey mockMyIdPrivateKey = getMockMyIDPrivateKey();
+ return JSONWebTokenUtils.createCertificate(publicKeyToSign, username, "mockmyid.com", issuedAt, expiresAt, mockMyIdPrivateKey);
+ }
+
+ /**
+ * Sign a public key asserting ownership of username@mockmyid.com with
+ * mockmyid.com's private key.
+ *
+ * @param publicKeyToSign
+ * public key to sign.
+ * @param username
+ * sign username@mockmyid.com
+ * @return encoded certificate string.
+ * @throws Exception
+ */
+ public String createMockMyIDCertificate(final VerifyingPublicKey publicKeyToSign, final String username)
+ throws Exception {
+ long ciat = System.currentTimeMillis();
+ long cexp = ciat + JSONWebTokenUtils.DEFAULT_CERTIFICATE_DURATION_IN_MILLISECONDS;
+ return createMockMyIDCertificate(publicKeyToSign, username, ciat, cexp);
+ }
+
+ /**
+ * Generate an assertion asserting ownership of username@mockmyid.com to a
+ * relying party. The underlying certificate is signed by mockymid.com's
+ * private key.
+ *
+ * @param keyPair
+ * to sign with.
+ * @param username
+ * sign username@mockmyid.com.
+ * @param certificateIssuedAt
+ * timestamp for certificate, in milliseconds since the epoch.
+ * @param certificateExpiresAt
+ * expiration timestamp for certificate, in milliseconds since the epoch.
+ * @param assertionIssuedAt
+ * timestamp for assertion, in milliseconds since the epoch; if null,
+ * no timestamp is included.
+ * @param assertionExpiresAt
+ * expiration timestamp for assertion, in milliseconds since the epoch.
+ * @return encoded assertion string.
+ * @throws Exception
+ */
+ public String createMockMyIDAssertion(BrowserIDKeyPair keyPair, String username, String audience,
+ long certificateIssuedAt, long certificateExpiresAt,
+ Long assertionIssuedAt, long assertionExpiresAt)
+ throws Exception {
+ String certificate = createMockMyIDCertificate(keyPair.getPublic(), username,
+ certificateIssuedAt, certificateExpiresAt);
+ return JSONWebTokenUtils.createAssertion(keyPair.getPrivate(), certificate, audience,
+ JSONWebTokenUtils.DEFAULT_ASSERTION_ISSUER, assertionIssuedAt, assertionExpiresAt);
+ }
+
+ /**
+ * Generate an assertion asserting ownership of username@mockmyid.com to a
+ * relying party. The underlying certificate is signed by mockymid.com's
+ * private key.
+ *
+ * @param keyPair
+ * to sign with.
+ * @param username
+ * sign username@mockmyid.com.
+ * @return encoded assertion string.
+ * @throws Exception
+ */
+ public String createMockMyIDAssertion(BrowserIDKeyPair keyPair, String username, String audience)
+ throws Exception {
+ long ciat = System.currentTimeMillis();
+ long cexp = ciat + JSONWebTokenUtils.DEFAULT_CERTIFICATE_DURATION_IN_MILLISECONDS;
+ long aiat = ciat + 1;
+ long aexp = aiat + JSONWebTokenUtils.DEFAULT_ASSERTION_DURATION_IN_MILLISECONDS;
+ return createMockMyIDAssertion(keyPair, username, audience,
+ ciat, cexp, aiat, aexp);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/RSACryptoImplementation.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/RSACryptoImplementation.java
new file mode 100644
index 000000000..902f6fb4d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/RSACryptoImplementation.java
@@ -0,0 +1,182 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid;
+
+import java.math.BigInteger;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.Signature;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+import java.security.spec.RSAPrivateKeySpec;
+import java.security.spec.RSAPublicKeySpec;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+
+public class RSACryptoImplementation {
+ public static final String SIGNATURE_ALGORITHM = "SHA256withRSA";
+
+ /**
+ * Parameters are serialized as decimal strings. Hex-versus-decimal was
+ * reverse-engineered from what the Persona public verifier accepted. We
+ * expect to follow the JOSE/JWT spec as it solidifies, and that will probably
+ * mean unifying this base.
+ */
+ protected static final int SERIALIZATION_BASE = 10;
+
+ protected static class RSAVerifyingPublicKey implements VerifyingPublicKey {
+ protected final RSAPublicKey publicKey;
+
+ public RSAVerifyingPublicKey(RSAPublicKey publicKey) {
+ this.publicKey = publicKey;
+ }
+
+ /**
+ * Serialize to a JSON object.
+ * <p>
+ * Parameters are serialized as decimal strings. Hex-versus-decimal was
+ * reverse-engineered from what the Persona public verifier accepted.
+ */
+ @Override
+ public ExtendedJSONObject toJSONObject() {
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put("algorithm", "RS");
+ o.put("n", publicKey.getModulus().toString(SERIALIZATION_BASE));
+ o.put("e", publicKey.getPublicExponent().toString(SERIALIZATION_BASE));
+ return o;
+ }
+
+ @Override
+ public boolean verifyMessage(byte[] bytes, byte[] signature)
+ throws GeneralSecurityException {
+ final Signature signer = Signature.getInstance(SIGNATURE_ALGORITHM);
+ signer.initVerify(publicKey);
+ signer.update(bytes);
+ return signer.verify(signature);
+ }
+ }
+
+ protected static class RSASigningPrivateKey implements SigningPrivateKey {
+ protected final RSAPrivateKey privateKey;
+
+ public RSASigningPrivateKey(RSAPrivateKey privateKey) {
+ this.privateKey = privateKey;
+ }
+
+ @Override
+ public String getAlgorithm() {
+ return "RS" + (privateKey.getModulus().bitLength() + 7)/8;
+ }
+
+ /**
+ * Serialize to a JSON object.
+ * <p>
+ * Parameters are serialized as decimal strings. Hex-versus-decimal was
+ * reverse-engineered from what the Persona public verifier accepted.
+ */
+ @Override
+ public ExtendedJSONObject toJSONObject() {
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put("algorithm", "RS");
+ o.put("n", privateKey.getModulus().toString(SERIALIZATION_BASE));
+ o.put("d", privateKey.getPrivateExponent().toString(SERIALIZATION_BASE));
+ return o;
+ }
+
+ @Override
+ public byte[] signMessage(byte[] bytes)
+ throws GeneralSecurityException {
+ final Signature signer = Signature.getInstance(SIGNATURE_ALGORITHM);
+ signer.initSign(privateKey);
+ signer.update(bytes);
+ return signer.sign();
+ }
+ }
+
+ public static BrowserIDKeyPair generateKeyPair(final int keysize) throws NoSuchAlgorithmException {
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(keysize);
+ final KeyPair keyPair = keyPairGenerator.generateKeyPair();
+ RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
+ RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
+ return new BrowserIDKeyPair(new RSASigningPrivateKey(privateKey), new RSAVerifyingPublicKey(publicKey));
+ }
+
+ public static SigningPrivateKey createPrivateKey(BigInteger n, BigInteger d) throws NoSuchAlgorithmException, InvalidKeySpecException {
+ if (n == null) {
+ throw new IllegalArgumentException("n must not be null");
+ }
+ if (d == null) {
+ throw new IllegalArgumentException("d must not be null");
+ }
+ KeyFactory keyFactory = KeyFactory.getInstance("RSA");
+ KeySpec keySpec = new RSAPrivateKeySpec(n, d);
+ RSAPrivateKey privateKey = (RSAPrivateKey) keyFactory.generatePrivate(keySpec);
+ return new RSASigningPrivateKey(privateKey);
+ }
+
+ public static VerifyingPublicKey createPublicKey(BigInteger n, BigInteger e) throws NoSuchAlgorithmException, InvalidKeySpecException {
+ if (n == null) {
+ throw new IllegalArgumentException("n must not be null");
+ }
+ if (e == null) {
+ throw new IllegalArgumentException("e must not be null");
+ }
+ KeyFactory keyFactory = KeyFactory.getInstance("RSA");
+ KeySpec keySpec = new RSAPublicKeySpec(n, e);
+ RSAPublicKey publicKey = (RSAPublicKey) keyFactory.generatePublic(keySpec);
+ return new RSAVerifyingPublicKey(publicKey);
+ }
+
+ public static SigningPrivateKey createPrivateKey(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException {
+ String algorithm = o.getString("algorithm");
+ if (!"RS".equals(algorithm)) {
+ throw new InvalidKeySpecException("algorithm must equal RS, was " + algorithm);
+ }
+ try {
+ BigInteger n = new BigInteger(o.getString("n"), SERIALIZATION_BASE);
+ BigInteger d = new BigInteger(o.getString("d"), SERIALIZATION_BASE);
+ return createPrivateKey(n, d);
+ } catch (NullPointerException | NumberFormatException e) {
+ throw new InvalidKeySpecException("n and d must be integers encoded as strings, base " + SERIALIZATION_BASE);
+ }
+ }
+
+ public static VerifyingPublicKey createPublicKey(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException {
+ String algorithm = o.getString("algorithm");
+ if (!"RS".equals(algorithm)) {
+ throw new InvalidKeySpecException("algorithm must equal RS, was " + algorithm);
+ }
+ try {
+ BigInteger n = new BigInteger(o.getString("n"), SERIALIZATION_BASE);
+ BigInteger e = new BigInteger(o.getString("e"), SERIALIZATION_BASE);
+ return createPublicKey(n, e);
+ } catch (NullPointerException | NumberFormatException e) {
+ throw new InvalidKeySpecException("n and e must be integers encoded as strings, base " + SERIALIZATION_BASE);
+ }
+ }
+
+ public static BrowserIDKeyPair fromJSONObject(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException {
+ try {
+ ExtendedJSONObject privateKey = o.getObject(BrowserIDKeyPair.JSON_KEY_PRIVATEKEY);
+ ExtendedJSONObject publicKey = o.getObject(BrowserIDKeyPair.JSON_KEY_PUBLICKEY);
+ if (privateKey == null) {
+ throw new InvalidKeySpecException("privateKey must not be null");
+ }
+ if (publicKey == null) {
+ throw new InvalidKeySpecException("publicKey must not be null");
+ }
+ return new BrowserIDKeyPair(createPrivateKey(privateKey), createPublicKey(publicKey));
+ } catch (NonObjectJSONException e) {
+ throw new InvalidKeySpecException("privateKey and publicKey must be JSON objects");
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/SigningPrivateKey.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/SigningPrivateKey.java
new file mode 100644
index 000000000..6c388d167
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/SigningPrivateKey.java
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid;
+
+import java.security.GeneralSecurityException;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+public interface SigningPrivateKey {
+ /**
+ * Return the JSON Web Token "alg" header corresponding to this private key.
+ * <p>
+ * The header is used when formatting web tokens, and generally denotes the
+ * algorithm and an ad-hoc encoding of the key size.
+ *
+ * @return header.
+ */
+ public String getAlgorithm();
+
+ /**
+ * Generate a JSON representation of a private key.
+ * <p>
+ * <b>This should only be used for debugging. No private keys should go over
+ * the wire at any time.</b>
+ *
+ * @param privateKey
+ * to represent.
+ * @return JSON representation.
+ */
+ public ExtendedJSONObject toJSONObject();
+
+ /**
+ * Sign a message.
+ * @param message to sign.
+ * @return signature.
+ * @throws GeneralSecurityException
+ */
+ public byte[] signMessage(byte[] message) throws GeneralSecurityException;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/VerifyingPublicKey.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/VerifyingPublicKey.java
new file mode 100644
index 000000000..74b534b90
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/VerifyingPublicKey.java
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid;
+
+import java.security.GeneralSecurityException;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+
+public interface VerifyingPublicKey {
+ /**
+ * Generate a JSON representation of a public key.
+ *
+ * @param publicKey
+ * to represent.
+ * @return JSON representation.
+ */
+ public ExtendedJSONObject toJSONObject();
+
+ /**
+ * Verify a signature.
+ *
+ * @param message
+ * to verify signature of.
+ * @param signature
+ * to verify.
+ * @return true if signature is a signature of message produced by the private
+ * key corresponding to this public key.
+ * @throws GeneralSecurityException
+ */
+ public boolean verifyMessage(byte[] message, byte[] signature) throws GeneralSecurityException;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/AbstractBrowserIDRemoteVerifierClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/AbstractBrowserIDRemoteVerifierClient.java
new file mode 100644
index 000000000..aa8db2d48
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/AbstractBrowserIDRemoteVerifierClient.java
@@ -0,0 +1,95 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid.verifier;
+
+import java.io.IOException;
+import java.net.URI;
+import java.security.GeneralSecurityException;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.browserid.verifier.BrowserIDVerifierException.BrowserIDVerifierErrorResponseException;
+import org.mozilla.gecko.browserid.verifier.BrowserIDVerifierException.BrowserIDVerifierMalformedResponseException;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.net.BaseResourceDelegate;
+import org.mozilla.gecko.sync.net.Resource;
+import org.mozilla.gecko.sync.net.SyncResponse;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+
+public abstract class AbstractBrowserIDRemoteVerifierClient implements BrowserIDVerifierClient {
+ public static final String LOG_TAG = AbstractBrowserIDRemoteVerifierClient.class.getSimpleName();
+
+ protected static class RemoteVerifierResourceDelegate extends BaseResourceDelegate {
+ private final BrowserIDVerifierDelegate delegate;
+
+ protected RemoteVerifierResourceDelegate(Resource resource, BrowserIDVerifierDelegate delegate) {
+ super(resource);
+ this.delegate = delegate;
+ }
+
+ @Override
+ public String getUserAgent() {
+ return null;
+ }
+
+ @Override
+ public void handleHttpResponse(HttpResponse response) {
+ SyncResponse res = new SyncResponse(response);
+ int statusCode = res.getStatusCode();
+ Logger.debug(LOG_TAG, "Got response with status code " + statusCode + ".");
+
+ if (statusCode != 200) {
+ delegate.handleError(new BrowserIDVerifierErrorResponseException("Expected status code 200."));
+ return;
+ }
+
+ ExtendedJSONObject o = null;
+ try {
+ o = res.jsonObjectBody();
+ } catch (Exception e) {
+ delegate.handleError(new BrowserIDVerifierMalformedResponseException(e));
+ return;
+ }
+
+ String status = o.getString("status");
+ if ("failure".equals(status)) {
+ delegate.handleFailure(o);
+ return;
+ }
+
+ if (!("okay".equals(status))) {
+ delegate.handleError(new BrowserIDVerifierMalformedResponseException("Expected status okay, got '" + status + "'."));
+ return;
+ }
+
+ delegate.handleSuccess(o);
+ }
+
+ @Override
+ public void handleTransportException(GeneralSecurityException e) {
+ Logger.warn(LOG_TAG, "Got transport exception.", e);
+ delegate.handleError(e);
+ }
+
+ @Override
+ public void handleHttpProtocolException(ClientProtocolException e) {
+ Logger.warn(LOG_TAG, "Got protocol exception.", e);
+ delegate.handleError(e);
+ }
+
+ @Override
+ public void handleHttpIOException(IOException e) {
+ Logger.warn(LOG_TAG, "Got IO exception.", e);
+ delegate.handleError(e);
+ }
+ }
+
+ protected final URI verifierUri;
+
+ public AbstractBrowserIDRemoteVerifierClient(URI verifierUri) {
+ this.verifierUri = verifierUri;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient10.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient10.java
new file mode 100644
index 000000000..f61a82323
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient10.java
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid.verifier;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Arrays;
+import java.util.List;
+
+import org.mozilla.gecko.sync.net.BaseResource;
+
+import ch.boye.httpclientandroidlib.NameValuePair;
+import ch.boye.httpclientandroidlib.client.entity.UrlEncodedFormEntity;
+import ch.boye.httpclientandroidlib.message.BasicNameValuePair;
+
+/**
+ * The verifier protocol changed: version 1 posts form-encoded data; version 2
+ * posts JSON data.
+ */
+public class BrowserIDRemoteVerifierClient10 extends AbstractBrowserIDRemoteVerifierClient {
+ public static final String LOG_TAG = BrowserIDRemoteVerifierClient10.class.getSimpleName();
+
+ public static final String DEFAULT_VERIFIER_URL = "https://verifier.login.persona.org/verify";
+
+ public BrowserIDRemoteVerifierClient10() throws URISyntaxException {
+ super(new URI(DEFAULT_VERIFIER_URL));
+ }
+
+ public BrowserIDRemoteVerifierClient10(URI verifierUri) {
+ super(verifierUri);
+ }
+
+ @Override
+ public void verify(String audience, String assertion, final BrowserIDVerifierDelegate delegate) {
+ if (audience == null) {
+ throw new IllegalArgumentException("audience cannot be null.");
+ }
+ if (assertion == null) {
+ throw new IllegalArgumentException("assertion cannot be null.");
+ }
+ if (delegate == null) {
+ throw new IllegalArgumentException("delegate cannot be null.");
+ }
+
+ BaseResource r = new BaseResource(verifierUri);
+
+ r.delegate = new RemoteVerifierResourceDelegate(r, delegate);
+
+ List<NameValuePair> nvps = Arrays.asList(new NameValuePair[] {
+ new BasicNameValuePair("audience", audience),
+ new BasicNameValuePair("assertion", assertion) });
+
+ try {
+ r.post(new UrlEncodedFormEntity(nvps, "UTF-8"));
+ } catch (UnsupportedEncodingException e) {
+ delegate.handleError(e);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient20.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient20.java
new file mode 100644
index 000000000..013856576
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDRemoteVerifierClient20.java
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid.verifier;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.net.BaseResource;
+
+/**
+ * The verifier protocol changed: version 1 posts form-encoded data; version 2
+ * posts JSON data.
+ */
+public class BrowserIDRemoteVerifierClient20 extends AbstractBrowserIDRemoteVerifierClient {
+ public static final String LOG_TAG = BrowserIDRemoteVerifierClient20.class.getSimpleName();
+
+ public static final String DEFAULT_VERIFIER_URL = "https://verifier.accounts.firefox.com/v2";
+
+ protected static final String JSON_KEY_ASSERTION = "assertion";
+ protected static final String JSON_KEY_AUDIENCE = "audience";
+
+ public BrowserIDRemoteVerifierClient20() throws URISyntaxException {
+ super(new URI(DEFAULT_VERIFIER_URL));
+ }
+
+ public BrowserIDRemoteVerifierClient20(URI verifierUri) {
+ super(verifierUri);
+ }
+
+ @Override
+ public void verify(String audience, String assertion, final BrowserIDVerifierDelegate delegate) {
+ if (audience == null) {
+ throw new IllegalArgumentException("audience cannot be null.");
+ }
+ if (assertion == null) {
+ throw new IllegalArgumentException("assertion cannot be null.");
+ }
+ if (delegate == null) {
+ throw new IllegalArgumentException("delegate cannot be null.");
+ }
+
+ BaseResource r = new BaseResource(verifierUri);
+ r.delegate = new RemoteVerifierResourceDelegate(r, delegate);
+
+ final ExtendedJSONObject requestBody = new ExtendedJSONObject();
+ requestBody.put(JSON_KEY_AUDIENCE, audience);
+ requestBody.put(JSON_KEY_ASSERTION, assertion);
+
+ try {
+ r.post(requestBody);
+ } catch (Exception e) {
+ delegate.handleError(e);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierClient.java
new file mode 100644
index 000000000..67a327f19
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierClient.java
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid.verifier;
+
+public interface BrowserIDVerifierClient {
+ public abstract void verify(String audience, String assertion, BrowserIDVerifierDelegate delegate);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierDelegate.java
new file mode 100644
index 000000000..b58d03281
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierDelegate.java
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid.verifier;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+public interface BrowserIDVerifierDelegate {
+ void handleSuccess(ExtendedJSONObject response);
+ void handleFailure(ExtendedJSONObject response);
+ void handleError(Exception e);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierException.java
new file mode 100644
index 000000000..dacaf6112
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/browserid/verifier/BrowserIDVerifierException.java
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browserid.verifier;
+
+public class BrowserIDVerifierException extends Exception {
+ private static final long serialVersionUID = 2228946910754889975L;
+
+ public BrowserIDVerifierException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public BrowserIDVerifierException(Throwable throwable) {
+ super(throwable);
+ }
+
+ public static class BrowserIDVerifierMalformedResponseException extends BrowserIDVerifierException {
+ private static final long serialVersionUID = 115377527009652839L;
+
+ public BrowserIDVerifierMalformedResponseException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public BrowserIDVerifierMalformedResponseException(Throwable throwable) {
+ super(throwable);
+ }
+ }
+
+ public static class BrowserIDVerifierErrorResponseException extends BrowserIDVerifierException {
+ private static final long serialVersionUID = 115377527009652840L;
+
+ public BrowserIDVerifierErrorResponseException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public BrowserIDVerifierErrorResponseException(Throwable throwable) {
+ super(throwable);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/AccountLoader.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/AccountLoader.java
new file mode 100644
index 000000000..8a31c1ce0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/AccountLoader.java
@@ -0,0 +1,227 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa;
+
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Handler;
+import android.os.Looper;
+import android.content.AsyncTaskLoader;
+import android.support.v4.content.LocalBroadcastManager;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * A Loader that queries and updates based on the existence of Firefox and
+ * legacy Sync Android Accounts.
+ *
+ * The loader returns an Android Account (of either Account type) if an account
+ * exists, and null to indicate no Account is present.
+ *
+ * The loader listens for Accounts added and deleted, and also Accounts being
+ * updated by Sync or another Activity, via the use of
+ * {@link AndroidFxAccount#setState(org.mozilla.gecko.fxa.login.State)}.
+ * Be careful of message loops if you update the account state from an activity
+ * that uses this loader.
+ *
+ * This implementation is based on
+ * <a href="http://www.androiddesignpatterns.com/2012/08/implementing-loaders.html">http://www.androiddesignpatterns.com/2012/08/implementing-loaders.html</a>.
+ */
+public class AccountLoader extends AsyncTaskLoader<Account> {
+ protected Account account = null;
+ protected BroadcastReceiver broadcastReceiver = null;
+
+ // Hold a weak reference to AccountLoader instance in this Runnable to avoid potentially leaking it
+ // after posting to a Handler in the BroadcastReceiver returned from makeNewObserver.
+ private final BroadcastReceiverRunnable broadcastReceiverRunnable = new BroadcastReceiverRunnable(this);
+
+ public AccountLoader(final Context context) {
+ super(context);
+ }
+
+ // Task that performs the asynchronous load.
+ @Override
+ public Account loadInBackground() {
+ return FirefoxAccounts.getFirefoxAccount(getContext());
+ }
+
+ // Deliver the results to the registered listener.
+ @Override
+ public void deliverResult(Account data) {
+ if (isReset()) {
+ // The Loader has been reset; ignore the result and invalidate the data.
+ releaseResources(data);
+ return;
+ }
+
+ // Hold a reference to the old data so it doesn't get garbage collected.
+ // We must protect it until the new data has been delivered.
+ Account oldData = account;
+ account = data;
+
+ if (isStarted()) {
+ // If the Loader is in a started state, deliver the results to the
+ // client. The superclass method does this for us.
+ super.deliverResult(data);
+ }
+
+ // Invalidate the old data as we don't need it any more.
+ if (oldData != null && oldData != data) {
+ releaseResources(oldData);
+ }
+ }
+
+ // The Loader’s state-dependent behavior.
+ @Override
+ protected void onStartLoading() {
+ if (account != null) {
+ // Deliver any previously loaded data immediately.
+ deliverResult(account);
+ }
+
+ // Begin monitoring the underlying data source.
+ if (broadcastReceiver == null) {
+ broadcastReceiver = makeNewObserver();
+ registerLocalObserver(getContext(), broadcastReceiver);
+ registerSystemObserver(getContext(), broadcastReceiver);
+ }
+
+ if (takeContentChanged() || account == null) {
+ // When the observer detects a change, it should call onContentChanged()
+ // on the Loader, which will cause the next call to takeContentChanged()
+ // to return true. If this is ever the case (or if the current data is
+ // null), we force a new load.
+ forceLoad();
+ }
+ }
+
+ @Override
+ protected void onStopLoading() {
+ // The Loader is in a stopped state, so we should attempt to cancel the
+ // current load (if there is one).
+ cancelLoad();
+
+ // Note that we leave the observer as is. Loaders in a stopped state
+ // should still monitor the data source for changes so that the Loader
+ // will know to force a new load if it is ever started again.
+ }
+
+ @Override
+ protected void onReset() {
+ // Ensure the loader has been stopped. In CursorLoader and the template
+ // this code follows (see the class comment), this is onStopLoading, which
+ // appears to not set the started flag (see Loader itself).
+ stopLoading();
+
+ // At this point we can release the resources associated with 'mData'.
+ if (account != null) {
+ releaseResources(account);
+ account = null;
+ }
+
+ // The Loader is being reset, so we should stop monitoring for changes.
+ if (broadcastReceiver != null) {
+ final BroadcastReceiver observer = broadcastReceiver;
+ broadcastReceiver = null;
+ unregisterObserver(getContext(), observer);
+ }
+ }
+
+ @Override
+ public void onCanceled(final Account data) {
+ // Attempt to cancel the current asynchronous load.
+ super.onCanceled(data);
+
+ // The load has been canceled, so we should release the resources
+ // associated with 'data'.
+ releaseResources(data);
+ }
+
+ // Observer which receives notifications when the data changes.
+ protected BroadcastReceiver makeNewObserver() {
+ return new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // onContentChanged must be called on the main thread.
+ // If we're already on the main thread, call it directly.
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ onContentChanged();
+ return;
+ }
+
+ // Otherwise, post a Runnable to a Handler bound to the main thread's message loop.
+ final Handler mainHandler = new Handler(Looper.getMainLooper());
+ mainHandler.post(broadcastReceiverRunnable);
+ }
+ };
+ }
+
+ private static class BroadcastReceiverRunnable implements Runnable {
+ private final WeakReference<AccountLoader> accountLoaderWeakReference;
+
+ public BroadcastReceiverRunnable(final AccountLoader accountLoader) {
+ accountLoaderWeakReference = new WeakReference<>(accountLoader);
+ }
+
+ @Override
+ public void run() {
+ final AccountLoader accountLoader = accountLoaderWeakReference.get();
+ if (accountLoader != null) {
+ accountLoader.onContentChanged();
+ }
+ }
+ }
+
+ private void releaseResources(Account data) {
+ // For a simple List, there is nothing to do. For something like a Cursor, we
+ // would close it in this method. All resources associated with the Loader
+ // should be released here.
+ }
+
+ /**
+ * Register provided observer with the LocalBroadcastManager to listen for internal events.
+ *
+ * @param context <code>Context</code> to use for obtaining LocalBroadcastManager instance.
+ * @param observer <code>BroadcastReceiver</code> which will handle local events.
+ */
+ protected static void registerLocalObserver(final Context context, final BroadcastReceiver observer) {
+ final IntentFilter intentFilter = new IntentFilter();
+ // Firefox Account internal state changed.
+ intentFilter.addAction(FxAccountConstants.ACCOUNT_STATE_CHANGED_ACTION);
+ // Firefox Account profile state changed.
+ intentFilter.addAction(FxAccountConstants.ACCOUNT_PROFILE_JSON_UPDATED_ACTION);
+
+ LocalBroadcastManager.getInstance(context).registerReceiver(observer, intentFilter);
+ }
+
+ /**
+ * Register provided observer for handling system-wide broadcasts.
+ *
+ * @param context <code>Context</code> to use for registering a receiver.
+ * @param observer <code>BroadcastReceiver</code> which will handle system events.
+ */
+ protected static void registerSystemObserver(final Context context, final BroadcastReceiver observer) {
+ context.registerReceiver(observer,
+ // Android Account added or removed.
+ new IntentFilter(AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION),
+ // No broadcast permissions required.
+ null,
+ // Null handler ensures that broadcasts will be handled on the main thread.
+ null
+ );
+ }
+
+ protected static void unregisterObserver(final Context context, final BroadcastReceiver observer) {
+ LocalBroadcastManager.getInstance(context).unregisterReceiver(observer);
+ context.unregisterReceiver(observer);
+ }
+}
+
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FirefoxAccounts.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FirefoxAccounts.java
new file mode 100644
index 000000000..4184340ec
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FirefoxAccounts.java
@@ -0,0 +1,222 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa;
+
+import java.io.File;
+import java.util.concurrent.CountDownLatch;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.fxa.authenticator.AccountPickler;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.sync.FxAccountSyncStatusHelper;
+import org.mozilla.gecko.sync.ThreadPool;
+import org.mozilla.gecko.sync.Utils;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.os.Bundle;
+
+/**
+ * Simple public accessors for Firefox account objects.
+ */
+public class FirefoxAccounts {
+ private static final String LOG_TAG = FirefoxAccounts.class.getSimpleName();
+
+ /**
+ * Returns true if a FirefoxAccount exists, false otherwise.
+ *
+ * @param context Android context.
+ * @return true if at least one Firefox account exists.
+ */
+ public static boolean firefoxAccountsExist(final Context context) {
+ return getFirefoxAccounts(context).length > 0;
+ }
+
+ /**
+ * Return Firefox accounts.
+ * <p>
+ * If no accounts exist in the AccountManager, one may be created
+ * via a pickled FirefoxAccount, if available, and that account
+ * will be added to the AccountManager and returned.
+ * <p>
+ * Note that this can be called from any thread.
+ *
+ * @param context Android context.
+ * @return Firefox account objects.
+ */
+ public static Account[] getFirefoxAccounts(final Context context) {
+ final Account[] accounts =
+ AccountManager.get(context).getAccountsByType(FxAccountConstants.ACCOUNT_TYPE);
+ if (accounts.length > 0) {
+ return accounts;
+ }
+
+ final Account pickledAccount = getPickledAccount(context);
+ return (pickledAccount != null) ? new Account[] {pickledAccount} : new Account[0];
+ }
+
+ private static Account getPickledAccount(final Context context) {
+ // To avoid a StrictMode violation for disk access, we call this from a background thread.
+ // We do this every time, so the caller doesn't have to care.
+ final CountDownLatch latch = new CountDownLatch(1);
+ final Account[] accounts = new Account[1];
+ ThreadPool.run(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ final File file = context.getFileStreamPath(FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
+ if (!file.exists()) {
+ accounts[0] = null;
+ return;
+ }
+
+ // There is a small race window here: if the user creates a new Firefox account
+ // between our checks, this could erroneously report that no Firefox accounts
+ // exist.
+ final AndroidFxAccount fxAccount =
+ AccountPickler.unpickle(context, FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
+ accounts[0] = fxAccount != null ? fxAccount.getAndroidAccount() : null;
+ } finally {
+ latch.countDown();
+ }
+ }
+ });
+
+ try {
+ latch.await(); // Wait for the background thread to return.
+ } catch (InterruptedException e) {
+ Logger.warn(LOG_TAG,
+ "Foreground thread unexpectedly interrupted while getting pickled account", e);
+ return null;
+ }
+
+ return accounts[0];
+ }
+
+ /**
+ * @param context Android context.
+ * @return the configured Firefox account if one exists, or null otherwise.
+ */
+ public static Account getFirefoxAccount(final Context context) {
+ Account[] accounts = getFirefoxAccounts(context);
+ if (accounts.length > 0) {
+ return accounts[0];
+ }
+ return null;
+ }
+
+ /**
+ * @return
+ * the {@link State} instance associated with the current account, or <code>null</code> if
+ * no accounts exist.
+ */
+ public static State getFirefoxAccountState(final Context context) {
+ final Account account = getFirefoxAccount(context);
+ if (account == null) {
+ return null;
+ }
+
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+ try {
+ return fxAccount.getState();
+ } catch (final Exception ex) {
+ Logger.warn(LOG_TAG, "Could not get FX account state.", ex);
+ return null;
+ }
+ }
+
+ /*
+ * @param context Android context
+ * @return the email address associated with the configured Firefox account if one exists; null otherwise.
+ */
+ public static String getFirefoxAccountEmail(final Context context) {
+ final Account account = getFirefoxAccount(context);
+ if (account == null) {
+ return null;
+ }
+ return account.name;
+ }
+
+ public static void logSyncOptions(Bundle syncOptions) {
+ final boolean scheduleNow = syncOptions.getBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, false);
+
+ Logger.info(LOG_TAG, "Sync options -- scheduling now: " + scheduleNow);
+ }
+
+ public static void requestImmediateSync(final Account account, String[] stagesToSync, String[] stagesToSkip) {
+ final Bundle syncOptions = new Bundle();
+ syncOptions.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true);
+ syncOptions.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true);
+ requestSync(account, syncOptions, stagesToSync, stagesToSkip);
+ }
+
+ public static void requestEventualSync(final Account account, String[] stagesToSync, String[] stagesToSkip) {
+ requestSync(account, Bundle.EMPTY, stagesToSync, stagesToSkip);
+ }
+
+ /**
+ * Request a sync for the given Android Account.
+ * <p>
+ * Any hints are strictly optional: the actual requested sync is scheduled by
+ * the Android sync scheduler, and the sync mechanism may ignore hints as it
+ * sees fit.
+ * <p>
+ * It is safe to call this method from any thread.
+ *
+ * @param account to sync.
+ * @param syncOptions to pass to sync.
+ * @param stagesToSync stage names to sync.
+ * @param stagesToSkip stage names to skip.
+ */
+ protected static void requestSync(final Account account, final Bundle syncOptions, String[] stagesToSync, String[] stagesToSkip) {
+ if (account == null) {
+ throw new IllegalArgumentException("account must not be null");
+ }
+ if (syncOptions == null) {
+ throw new IllegalArgumentException("syncOptions must not be null");
+ }
+
+ Utils.putStageNamesToSync(syncOptions, stagesToSync, stagesToSkip);
+
+ Logger.info(LOG_TAG, "Requesting sync.");
+ logSyncOptions(syncOptions);
+
+ // We get strict mode warnings on some devices, so make the request on a
+ // background thread.
+ ThreadPool.run(new Runnable() {
+ @Override
+ public void run() {
+ for (String authority : AndroidFxAccount.DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP.keySet()) {
+ ContentResolver.requestSync(account, authority, syncOptions);
+ }
+ }
+ });
+ }
+
+ /**
+ * Start notifying <code>syncStatusListener</code> of sync status changes.
+ * <p>
+ * Only a weak reference to <code>syncStatusListener</code> is held.
+ *
+ * @param syncStatusListener to start notifying.
+ */
+ public static void addSyncStatusListener(SyncStatusListener syncStatusListener) {
+ // startObserving null-checks its argument.
+ FxAccountSyncStatusHelper.getInstance().startObserving(syncStatusListener);
+ }
+
+ /**
+ * Stop notifying <code>syncStatusListener</code> of sync status changes.
+ *
+ * @param syncStatusListener to stop notifying.
+ */
+ public static void removeSyncStatusListener(SyncStatusListener syncStatusListener) {
+ // stopObserving null-checks its argument.
+ FxAccountSyncStatusHelper.getInstance().stopObserving(syncStatusListener);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountConstants.java
new file mode 100644
index 000000000..c6147b323
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountConstants.java
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa;
+
+import org.mozilla.gecko.AppConstants;
+
+public class FxAccountConstants {
+ public static final String GLOBAL_LOG_TAG = "FxAccounts";
+ public static final String ACCOUNT_TYPE = AppConstants.MOZ_ANDROID_SHARED_FXACCOUNT_TYPE;
+
+ // Must be a client ID allocated with "canGrant" privileges!
+ public static final String OAUTH_CLIENT_ID_FENNEC = "3332a18d142636cb";
+
+ public static final String DEFAULT_AUTH_SERVER_ENDPOINT = "https://api.accounts.firefox.com/v1";
+ public static final String DEFAULT_TOKEN_SERVER_ENDPOINT = "https://token.services.mozilla.com/1.0/sync/1.5";
+ public static final String DEFAULT_OAUTH_SERVER_ENDPOINT = "https://oauth.accounts.firefox.com/v1";
+ public static final String DEFAULT_PROFILE_SERVER_ENDPOINT = "https://profile.accounts.firefox.com/v1";
+
+ public static final String STAGE_AUTH_SERVER_ENDPOINT = "https://stable.dev.lcip.org/auth/v1";
+ public static final String STAGE_TOKEN_SERVER_ENDPOINT = "https://stable.dev.lcip.org/syncserver/token/1.0/sync/1.5";
+ public static final String STAGE_OAUTH_SERVER_ENDPOINT = "https://oauth-stable.dev.lcip.org/v1";
+ public static final String STAGE_PROFILE_SERVER_ENDPOINT = "https://latest.dev.lcip.org/profile/v1";
+
+ // Action to update on cached profile information.
+ public static final String ACCOUNT_PROFILE_JSON_UPDATED_ACTION = "org.mozilla.gecko.fxa.profile.JSON.updated";
+
+ // You must be at least 13 years old, on the day of creation, to create a Firefox Account.
+ public static final int MINIMUM_AGE_TO_CREATE_AN_ACCOUNT = 13;
+
+ // Key for avatar URI in profile JSON.
+ public static final String KEY_PROFILE_JSON_AVATAR = "avatar";
+ // Key for username in profile JSON.
+ public static final String KEY_PROFILE_JSON_USERNAME = "displayName";
+
+ // You must wait 15 minutes after failing an age check before trying to create a different account.
+ public static final long MINIMUM_TIME_TO_WAIT_AFTER_AGE_CHECK_FAILED_IN_MILLISECONDS = 15 * 60 * 1000;
+
+ public static final String USER_AGENT = "Firefox-Android-FxAccounts/" + AppConstants.MOZ_APP_VERSION + " (" + AppConstants.MOZ_APP_UA_NAME + ")";
+
+ public static final String ACCOUNT_PICKLE_FILENAME = "fxa.account.json";
+
+
+ /**
+ * Version number of contents of SYNC_ACCOUNT_DELETED_ACTION intent.
+ */
+ public static final long ACCOUNT_DELETED_INTENT_VERSION = 1;
+
+ public static final String ACCOUNT_DELETED_INTENT_VERSION_KEY = "account_deleted_intent_version";
+ public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_KEY = "account_deleted_intent_account";
+ public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_PROFILE = "account_deleted_intent_profile";
+ public static final String ACCOUNT_OAUTH_SERVICE_ENDPOINT_KEY = "account_oauth_service_endpoint";
+ public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_AUTH_TOKENS = "account_deleted_intent_auth_tokens";
+
+ /**
+ * This action is broadcast when an Android Firefox Account's internal state
+ * is changed.
+ * <p>
+ * It is protected by signing-level permission PER_ACCOUNT_TYPE_PERMISSION and
+ * can be received only by Firefox versions sharing the same Android Firefox
+ * Account type.
+ */
+ public static final String ACCOUNT_STATE_CHANGED_ACTION = AppConstants.MOZ_ANDROID_SHARED_FXACCOUNT_TYPE + ".accounts.ACCOUNT_STATE_CHANGED_ACTION";
+
+ public static final String ACTION_FXA_CONFIRM_ACCOUNT = AppConstants.ANDROID_PACKAGE_NAME + ".ACTION_FXA_CONFIRM_ACCOUNT";
+ public static final String ACTION_FXA_FINISH_MIGRATING = AppConstants.ANDROID_PACKAGE_NAME + ".ACTION_FXA_FINISH_MIGRATING";
+ public static final String ACTION_FXA_GET_STARTED = AppConstants.ANDROID_PACKAGE_NAME + ".ACTION_FXA_GET_STARTED";
+ public static final String ACTION_FXA_STATUS = AppConstants.ANDROID_PACKAGE_NAME + ".ACTION_FXA_STATUS";
+ public static final String ACTION_FXA_UPDATE_CREDENTIALS = AppConstants.ANDROID_PACKAGE_NAME + ".ACTION_FXA_UPDATE_CREDENTIALS";
+
+ public static final String ENDPOINT_PREFERENCES = "preferences";
+ public static final String ENDPOINT_NOTIFICATION = "notification";
+ public static final String ENDPOINT_FIRSTRUN = "firstrun";
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDevice.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDevice.java
new file mode 100644
index 000000000..cd46ae2bd
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDevice.java
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+public class FxAccountDevice {
+
+ public static final String JSON_KEY_NAME = "name";
+ public static final String JSON_KEY_ID = "id";
+ public static final String JSON_KEY_TYPE = "type";
+ public static final String JSON_KEY_ISCURRENTDEVICE = "isCurrentDevice";
+ public static final String JSON_KEY_PUSH_CALLBACK = "pushCallback";
+ public static final String JSON_KEY_PUSH_PUBLICKEY = "pushPublicKey";
+ public static final String JSON_KEY_PUSH_AUTHKEY = "pushAuthKey";
+
+ public final String id;
+ public final String name;
+ public final String type;
+ public final Boolean isCurrentDevice;
+ public final String pushCallback;
+ public final String pushPublicKey;
+ public final String pushAuthKey;
+
+ public FxAccountDevice(String name, String id, String type, Boolean isCurrentDevice,
+ String pushCallback, String pushPublicKey, String pushAuthKey) {
+ this.name = name;
+ this.id = id;
+ this.type = type;
+ this.isCurrentDevice = isCurrentDevice;
+ this.pushCallback = pushCallback;
+ this.pushPublicKey = pushPublicKey;
+ this.pushAuthKey = pushAuthKey;
+ }
+
+ public static FxAccountDevice forRegister(String name, String type, String pushCallback,
+ String pushPublicKey, String pushAuthKey) {
+ return new FxAccountDevice(name, null, type, null, pushCallback, pushPublicKey, pushAuthKey);
+ }
+
+ public static FxAccountDevice forUpdate(String id, String name, String pushCallback,
+ String pushPublicKey, String pushAuthKey) {
+ return new FxAccountDevice(name, id, null, null, pushCallback, pushPublicKey, pushAuthKey);
+ }
+
+ public static FxAccountDevice fromJson(ExtendedJSONObject json) {
+ String name = json.getString(JSON_KEY_NAME);
+ String id = json.getString(JSON_KEY_ID);
+ String type = json.getString(JSON_KEY_TYPE);
+ Boolean isCurrentDevice = json.getBoolean(JSON_KEY_ISCURRENTDEVICE);
+ String pushCallback = json.getString(JSON_KEY_PUSH_CALLBACK);
+ String pushPublicKey = json.getString(JSON_KEY_PUSH_PUBLICKEY);
+ String pushAuthKey = json.getString(JSON_KEY_PUSH_AUTHKEY);
+ return new FxAccountDevice(name, id, type, isCurrentDevice, pushCallback, pushPublicKey, pushAuthKey);
+ }
+
+ public ExtendedJSONObject toJson() {
+ final ExtendedJSONObject body = new ExtendedJSONObject();
+ if (this.name != null) {
+ body.put(JSON_KEY_NAME, this.name);
+ }
+ if (this.id != null) {
+ body.put(JSON_KEY_ID, this.id);
+ }
+ if (this.type != null) {
+ body.put(JSON_KEY_TYPE, this.type);
+ }
+ if (this.pushCallback != null) {
+ body.put(JSON_KEY_PUSH_CALLBACK, this.pushCallback);
+ }
+ if (this.pushPublicKey != null) {
+ body.put(JSON_KEY_PUSH_PUBLICKEY, this.pushPublicKey);
+ }
+ if (this.pushAuthKey != null) {
+ body.put(JSON_KEY_PUSH_AUTHKEY, this.pushAuthKey);
+ }
+ return body;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java
new file mode 100644
index 000000000..66a8ad843
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java
@@ -0,0 +1,282 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.AccountStatusResponse;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.RequestDelegate;
+import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
+import org.mozilla.gecko.background.fxa.FxAccountRemoteError;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount.InvalidFxAState;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+
+import java.io.UnsupportedEncodingException;
+import java.lang.ref.WeakReference;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.security.GeneralSecurityException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/* This class provides a way to register the current device against FxA
+ * and also stores the registration details in the Android FxAccount.
+ * This should be used in a state where we possess a sessionToken, most likely the Married state.
+ */
+public class FxAccountDeviceRegistrator implements BundleEventListener {
+ private static final String LOG_TAG = "FxADeviceRegistrator";
+
+ // The current version of the device registration, we use this to re-register
+ // devices after we update what we send on device registration.
+ public static final Integer DEVICE_REGISTRATION_VERSION = 2;
+
+ private static FxAccountDeviceRegistrator instance;
+ private final WeakReference<Context> context;
+
+ private FxAccountDeviceRegistrator(Context appContext) {
+ this.context = new WeakReference<Context>(appContext);
+ }
+
+ private static FxAccountDeviceRegistrator getInstance(Context appContext) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
+ if (instance == null) {
+ FxAccountDeviceRegistrator tempInstance = new FxAccountDeviceRegistrator(appContext);
+ tempInstance.setupListeners(); // Set up listener for FxAccountPush:Subscribe:Response
+ instance = tempInstance;
+ }
+ return instance;
+ }
+
+ public static void register(Context context) {
+ Context appContext = context.getApplicationContext();
+ try {
+ getInstance(appContext).beginRegistration(appContext);
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Could not start FxA device registration", e);
+ }
+ }
+
+ private void beginRegistration(Context context) {
+ // Fire up gecko and send event
+ // We create the Intent ourselves instead of using GeckoService.getIntentToCreateServices
+ // because we can't import these modules (circular dependency between browser and services)
+ final Intent geckoIntent = new Intent();
+ geckoIntent.setAction("create-services");
+ geckoIntent.setClassName(context, "org.mozilla.gecko.GeckoService");
+ geckoIntent.putExtra("category", "android-push-service");
+ geckoIntent.putExtra("data", "android-fxa-subscribe");
+ final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
+ geckoIntent.putExtra("org.mozilla.gecko.intent.PROFILE_NAME", fxAccount.getProfile());
+ context.startService(geckoIntent);
+ // -> handleMessage()
+ }
+
+ @Override
+ public void handleMessage(String event, Bundle message, EventCallback callback) {
+ if ("FxAccountsPush:Subscribe:Response".equals(event)) {
+ try {
+ doFxaRegistration(message.getBundle("subscription"));
+ } catch (InvalidFxAState e) {
+ Log.d(LOG_TAG, "Invalid state when trying to register with FxA ", e);
+ }
+ } else {
+ Log.e(LOG_TAG, "No action defined for " + event);
+ }
+ }
+
+ private void doFxaRegistration(Bundle subscription) throws InvalidFxAState {
+ final Context context = this.context.get();
+ if (this.context == null) {
+ throw new IllegalStateException("Application context has been gc'ed");
+ }
+ doFxaRegistration(context, subscription, true);
+ }
+
+ private static void doFxaRegistration(final Context context, final Bundle subscription, final boolean allowRecursion) throws InvalidFxAState {
+ String pushCallback = subscription.getString("pushCallback");
+ String pushPublicKey = subscription.getString("pushPublicKey");
+ String pushAuthKey = subscription.getString("pushAuthKey");
+
+ final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
+ if (fxAccount == null) {
+ Log.e(LOG_TAG, "AndroidFxAccount is null");
+ return;
+ }
+ final byte[] sessionToken = fxAccount.getSessionToken();
+ final FxAccountDevice device;
+ String deviceId = fxAccount.getDeviceId();
+ String clientName = getClientName(fxAccount, context);
+ if (TextUtils.isEmpty(deviceId)) {
+ Log.i(LOG_TAG, "Attempting registration for a new device");
+ device = FxAccountDevice.forRegister(clientName, "mobile", pushCallback, pushPublicKey, pushAuthKey);
+ } else {
+ Log.i(LOG_TAG, "Attempting registration for an existing device");
+ Logger.pii(LOG_TAG, "Device ID: " + deviceId);
+ device = FxAccountDevice.forUpdate(deviceId, clientName, pushCallback, pushPublicKey, pushAuthKey);
+ }
+
+ ExecutorService executor = Executors.newSingleThreadExecutor(); // Not called often, it's okay to spawn another thread
+ final FxAccountClient20 fxAccountClient =
+ new FxAccountClient20(fxAccount.getAccountServerURI(), executor);
+ fxAccountClient.registerOrUpdateDevice(sessionToken, device, new RequestDelegate<FxAccountDevice>() {
+ @Override
+ public void handleError(Exception e) {
+ Log.e(LOG_TAG, "Error while updating a device registration: ", e);
+ }
+
+ @Override
+ public void handleFailure(FxAccountClientRemoteException error) {
+ Log.e(LOG_TAG, "Error while updating a device registration: ", error);
+ if (error.httpStatusCode == 400) {
+ if (error.apiErrorNumber == FxAccountRemoteError.UNKNOWN_DEVICE) {
+ recoverFromUnknownDevice(fxAccount);
+ } else if (error.apiErrorNumber == FxAccountRemoteError.DEVICE_SESSION_CONFLICT) {
+ recoverFromDeviceSessionConflict(error, fxAccountClient, sessionToken, fxAccount, context,
+ subscription, allowRecursion);
+ }
+ } else
+ if (error.httpStatusCode == 401
+ && error.apiErrorNumber == FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN) {
+ handleTokenError(error, fxAccountClient, fxAccount);
+ } else {
+ logErrorAndResetDeviceRegistrationVersion(error, fxAccount);
+ }
+ }
+
+ @Override
+ public void handleSuccess(FxAccountDevice result) {
+ Log.i(LOG_TAG, "Device registration complete");
+ Logger.pii(LOG_TAG, "Registered device ID: " + result.id);
+ fxAccount.setFxAUserData(result.id, DEVICE_REGISTRATION_VERSION);
+ }
+ });
+ }
+
+ private static void logErrorAndResetDeviceRegistrationVersion(
+ final FxAccountClientRemoteException error, final AndroidFxAccount fxAccount) {
+ Log.e(LOG_TAG, "Device registration failed", error);
+ fxAccount.resetDeviceRegistrationVersion();
+ }
+
+ @Nullable
+ private static String getClientName(final AndroidFxAccount fxAccount, final Context context) {
+ try {
+ SharedPreferencesClientsDataDelegate clientsDataDelegate =
+ new SharedPreferencesClientsDataDelegate(fxAccount.getSyncPrefs(), context);
+ return clientsDataDelegate.getClientName();
+ } catch (UnsupportedEncodingException | GeneralSecurityException e) {
+ Log.e(LOG_TAG, "Unable to get client name.", e);
+ return null;
+ }
+ }
+
+ private static void handleTokenError(final FxAccountClientRemoteException error,
+ final FxAccountClient fxAccountClient,
+ final AndroidFxAccount fxAccount) {
+ Log.i(LOG_TAG, "Recovering from invalid token error: ", error);
+ logErrorAndResetDeviceRegistrationVersion(error, fxAccount);
+ fxAccountClient.accountStatus(fxAccount.getState().uid,
+ new RequestDelegate<AccountStatusResponse>() {
+ @Override
+ public void handleError(Exception e) {
+ }
+
+ @Override
+ public void handleFailure(FxAccountClientRemoteException e) {
+ }
+
+ @Override
+ public void handleSuccess(AccountStatusResponse result) {
+ State doghouseState = fxAccount.getState().makeDoghouseState();
+ if (!result.exists) {
+ Log.i(LOG_TAG, "token invalidated because the account no longer exists");
+ // TODO: Should be in a "I have an Android account, but the FxA is gone." State.
+ // This will do for now..
+ fxAccount.setState(doghouseState);
+ return;
+ }
+ Log.e(LOG_TAG, "sessionToken invalid");
+ fxAccount.setState(doghouseState);
+ }
+ });
+ }
+
+ private static void recoverFromUnknownDevice(final AndroidFxAccount fxAccount) {
+ Log.i(LOG_TAG, "unknown device id, clearing the cached device id");
+ fxAccount.setDeviceId(null);
+ }
+
+ /**
+ * Will call delegate#complete in all cases
+ */
+ private static void recoverFromDeviceSessionConflict(final FxAccountClientRemoteException error,
+ final FxAccountClient fxAccountClient,
+ final byte[] sessionToken,
+ final AndroidFxAccount fxAccount,
+ final Context context,
+ final Bundle subscription,
+ final boolean allowRecursion) {
+ Log.w(LOG_TAG, "device session conflict, attempting to ascertain the correct device id");
+ fxAccountClient.deviceList(sessionToken, new RequestDelegate<FxAccountDevice[]>() {
+ private void onError() {
+ Log.e(LOG_TAG, "failed to recover from device-session conflict");
+ logErrorAndResetDeviceRegistrationVersion(error, fxAccount);
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ onError();
+ }
+
+ @Override
+ public void handleFailure(FxAccountClientRemoteException e) {
+ onError();
+ }
+
+ @Override
+ public void handleSuccess(FxAccountDevice[] devices) {
+ for (FxAccountDevice device : devices) {
+ if (device.isCurrentDevice) {
+ fxAccount.setFxAUserData(device.id, 0); // Reset device registration version
+ if (!allowRecursion) {
+ Log.d(LOG_TAG, "Failure to register a device on the second try");
+ break;
+ }
+ try {
+ doFxaRegistration(context, subscription, false);
+ return;
+ } catch (InvalidFxAState e) {
+ Log.d(LOG_TAG, "Invalid state when trying to recover from a session conflict ", e);
+ break;
+ }
+ }
+ }
+ onError();
+ }
+ });
+ }
+
+ private void setupListeners() throws ClassNotFoundException, NoSuchMethodException,
+ InvocationTargetException, IllegalAccessException {
+ // We have no choice but to use reflection here, sorry :(
+ Class<?> eventDispatcher = Class.forName("org.mozilla.gecko.EventDispatcher");
+ Method getInstance = eventDispatcher.getMethod("getInstance");
+ Object instance = getInstance.invoke(null);
+ Method registerBackgroundThreadListener = eventDispatcher.getMethod("registerBackgroundThreadListener",
+ BundleEventListener.class, String[].class);
+ registerBackgroundThreadListener.invoke(instance, this, new String[] { "FxAccountsPush:Subscribe:Response" });
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java
new file mode 100644
index 000000000..0117e6320
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java
@@ -0,0 +1,95 @@
+package org.mozilla.gecko.fxa;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.content.Context;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+
+public class FxAccountPushHandler {
+ private static final String LOG_TAG = "FxAccountPush";
+
+ private static final String COMMAND_DEVICE_DISCONNECTED = "fxaccounts:device_disconnected";
+ private static final String COMMAND_COLLECTION_CHANGED = "sync:collection_changed";
+
+ private static final String CLIENTS_COLLECTION = "clients";
+
+ // Forbid instantiation
+ private FxAccountPushHandler() {}
+
+ public static void handleFxAPushMessage(Context context, Bundle bundle) {
+ Log.i(LOG_TAG, "Handling FxA Push Message");
+ String rawMessage = bundle.getString("message");
+ JSONObject message = null;
+ if (!TextUtils.isEmpty(rawMessage)) {
+ try {
+ message = new JSONObject(rawMessage);
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Could not parse JSON", e);
+ return;
+ }
+ }
+ if (message == null) {
+ // An empty body means we should check the verification state of the account (FxA sends this
+ // when the account email is verified for example).
+ // TODO: We're only registering the push endpoint when we are in the Married state, that's why we're skipping the message :(
+ Log.d(LOG_TAG, "Skipping empty message");
+ return;
+ }
+ try {
+ String command = message.getString("command");
+ JSONObject data = message.getJSONObject("data");
+ switch (command) {
+ case COMMAND_DEVICE_DISCONNECTED:
+ handleDeviceDisconnection(context, data);
+ break;
+ case COMMAND_COLLECTION_CHANGED:
+ handleCollectionChanged(context, data);
+ break;
+ default:
+ Log.d(LOG_TAG, "No handler defined for FxA Push command " + command);
+ break;
+ }
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Error while handling FxA push notification", e);
+ }
+ }
+
+ private static void handleCollectionChanged(Context context, JSONObject data) throws JSONException {
+ JSONArray collections = data.getJSONArray("collections");
+ int len = collections.length();
+ for (int i = 0; i < len; i++) {
+ if (collections.getString(i).equals(CLIENTS_COLLECTION)) {
+ final Account account = FirefoxAccounts.getFirefoxAccount(context);
+ if (account == null) {
+ Log.e(LOG_TAG, "The account does not exist anymore");
+ return;
+ }
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+ fxAccount.requestImmediateSync(new String[] { CLIENTS_COLLECTION }, null);
+ return;
+ }
+ }
+ }
+
+ private static void handleDeviceDisconnection(Context context, JSONObject data) throws JSONException {
+ final Account account = FirefoxAccounts.getFirefoxAccount(context);
+ if (account == null) {
+ Log.e(LOG_TAG, "The account does not exist anymore");
+ return;
+ }
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+ if (!fxAccount.getDeviceId().equals(data.getString("id"))) {
+ Log.e(LOG_TAG, "The device ID to disconnect doesn't match with the local device ID.\n"
+ + "Local: " + fxAccount.getDeviceId() + ", ID to disconnect: " + data.getString("id"));
+ return;
+ }
+ AccountManager.get(context).removeAccount(account, null, null);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/SyncStatusListener.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/SyncStatusListener.java
new file mode 100644
index 000000000..2f70a363a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/SyncStatusListener.java
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.support.annotation.UiThread;
+
+/**
+ * Interface definition for a callback to be invoked when an sync status change.
+ */
+public interface SyncStatusListener {
+ public Context getContext();
+ public Account getAccount();
+
+ /**
+ * Called when sync has started.
+ * This is always called in UiThread.
+ */
+ @UiThread
+ public void onSyncStarted();
+
+ /**
+ * Called when sync has finished.
+ * This is always called in UiThread.
+ */
+ @UiThread
+ public void onSyncFinished();
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/CustomColorPreference.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/CustomColorPreference.java
new file mode 100644
index 000000000..5c4d7f3cc
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/CustomColorPreference.java
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.activities;
+
+import org.mozilla.gecko.R;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.preference.Preference;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.TextView;
+
+ /**
+ * This preference is used to define custom colors for both title and summary texts.
+ * Color code #777777 (placeholder_grey) is used as the fallback color for both title and summary.
+ */
+public class CustomColorPreference extends Preference {
+ private int mTitleColor;
+ private int mSummaryColor;
+
+ public CustomColorPreference(Context context) {
+ super(context);
+ }
+
+ public CustomColorPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(context, attrs);
+ }
+
+ public CustomColorPreference(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init(context, attrs);
+ }
+
+ public void init(Context context, AttributeSet attrs) {
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CustomColorPreference);
+ mTitleColor = a.getColor(R.styleable.CustomColorPreference_titleColor, R.color.placeholder_grey);
+ mSummaryColor = a.getColor(R.styleable.CustomColorPreference_summaryColor, R.color.placeholder_grey);
+ a.recycle();
+ }
+
+ @Override
+ protected void onBindView(View view) {
+ super.onBindView(view);
+ final TextView title = (TextView) view.findViewById(android.R.id.title);
+ final TextView summary = (TextView) view.findViewById(android.R.id.summary);
+ title.setTextColor(mTitleColor);
+ summary.setTextColor(mSummaryColor);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountAbstractActivity.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountAbstractActivity.java
new file mode 100644
index 000000000..fc8cbf0da
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountAbstractActivity.java
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.activities;
+
+import android.accounts.Account;
+import android.app.Activity;
+import android.content.Intent;
+
+import org.mozilla.gecko.Locales.LocaleAwareActivity;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+
+public abstract class FxAccountAbstractActivity extends LocaleAwareActivity {
+ private static final String LOG_TAG = FxAccountAbstractActivity.class.getSimpleName();
+
+ protected final boolean cannotResumeWhenAccountsExist;
+ protected final boolean cannotResumeWhenNoAccountsExist;
+
+ public static final int CAN_ALWAYS_RESUME = 0;
+ public static final int CANNOT_RESUME_WHEN_ACCOUNTS_EXIST = 1 << 0;
+ public static final int CANNOT_RESUME_WHEN_NO_ACCOUNTS_EXIST = 1 << 1;
+
+ public FxAccountAbstractActivity(int resume) {
+ super();
+ this.cannotResumeWhenAccountsExist = 0 != (resume & CANNOT_RESUME_WHEN_ACCOUNTS_EXIST);
+ this.cannotResumeWhenNoAccountsExist = 0 != (resume & CANNOT_RESUME_WHEN_NO_ACCOUNTS_EXIST);
+ }
+
+ /**
+ * Many Firefox Accounts activities shouldn't display if an account already
+ * exists. This function redirects as appropriate.
+ *
+ * @return true if redirected.
+ */
+ protected boolean redirectIfAppropriate() {
+ if (cannotResumeWhenAccountsExist || cannotResumeWhenNoAccountsExist) {
+ final Account account = FirefoxAccounts.getFirefoxAccount(this);
+ if (cannotResumeWhenAccountsExist && account != null) {
+ redirectToAction(FxAccountConstants.ACTION_FXA_STATUS);
+ return true;
+ }
+ if (cannotResumeWhenNoAccountsExist && account == null) {
+ redirectToAction(FxAccountConstants.ACTION_FXA_GET_STARTED);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ redirectIfAppropriate();
+ }
+
+ @Override
+ public void onBackPressed() {
+ super.onBackPressed();
+ overridePendingTransition(0, 0);
+ }
+
+ protected void launchActivity(Class<? extends Activity> activityClass) {
+ Intent intent = new Intent(this, activityClass);
+ // Per http://stackoverflow.com/a/8992365, this triggers a known bug with
+ // the soft keyboard not being shown for the started activity. Why, Android, why?
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(intent);
+ }
+
+ protected void redirectToAction(final String action) {
+ final Intent intent = new Intent(action);
+ // Per http://stackoverflow.com/a/8992365, this triggers a known bug with
+ // the soft keyboard not being shown for the started activity. Why, Android, why?
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(intent);
+ finish();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountConfirmAccountActivityWeb.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountConfirmAccountActivityWeb.java
new file mode 100644
index 000000000..b2afd9c5a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountConfirmAccountActivityWeb.java
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.activities;
+
+public class FxAccountConfirmAccountActivityWeb extends FxAccountWebFlowActivity {
+ public FxAccountConfirmAccountActivityWeb() {
+ super(CANNOT_RESUME_WHEN_NO_ACCOUNTS_EXIST, "manage");
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountFinishMigratingActivityWeb.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountFinishMigratingActivityWeb.java
new file mode 100644
index 000000000..0e66f1d6c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountFinishMigratingActivityWeb.java
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.activities;
+
+public class FxAccountFinishMigratingActivityWeb extends FxAccountWebFlowActivity {
+ public FxAccountFinishMigratingActivityWeb() {
+ super(CANNOT_RESUME_WHEN_NO_ACCOUNTS_EXIST, "signin", "migration=sync11");
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountGetStartedActivityWeb.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountGetStartedActivityWeb.java
new file mode 100644
index 000000000..39a907a44
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountGetStartedActivityWeb.java
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.activities;
+
+public class FxAccountGetStartedActivityWeb extends FxAccountWebFlowActivity {
+ public FxAccountGetStartedActivityWeb() {
+ super(CANNOT_RESUME_WHEN_ACCOUNTS_EXIST, "signup");
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusActivity.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusActivity.java
new file mode 100644
index 000000000..4bb929f0a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusActivity.java
@@ -0,0 +1,228 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.activities;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.AccountManagerCallback;
+import android.accounts.AccountManagerFuture;
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.support.v7.widget.Toolbar;
+import android.util.TypedValue;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.Window;
+import android.widget.Toast;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.Locales.LocaleAwareAppCompatActivity;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.sync.Utils;
+
+/**
+ * Activity which displays account status.
+ */
+public class FxAccountStatusActivity extends LocaleAwareAppCompatActivity {
+ private static final String LOG_TAG = FxAccountStatusActivity.class.getSimpleName();
+
+ protected FxAccountStatusFragment statusFragment;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Display the fragment as the content.
+ statusFragment = new FxAccountStatusFragment();
+ getSupportFragmentManager()
+ .beginTransaction()
+ .replace(android.R.id.content, statusFragment)
+ .commit();
+
+ maybeSetHomeButtonEnabled();
+ }
+
+ /**
+ * Sufficiently recent Android versions need additional code to receive taps
+ * on the status bar to go "up". See <a
+ * href="http://stackoverflow.com/a/8953148">this stackoverflow answer</a> for
+ * more information.
+ */
+ @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
+ protected void maybeSetHomeButtonEnabled() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+ Logger.debug(LOG_TAG, "Not enabling home button; version too low.");
+ return;
+ }
+ final ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ Logger.debug(LOG_TAG, "Enabling home button.");
+ actionBar.setHomeButtonEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ return;
+ }
+ Logger.debug(LOG_TAG, "Not enabling home button.");
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ final AndroidFxAccount fxAccount = getAndroidFxAccount();
+ if (fxAccount == null) {
+ Logger.warn(LOG_TAG, "Could not get Firefox Account.");
+
+ // Gracefully redirect to get started.
+ final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED);
+ // Per http://stackoverflow.com/a/8992365, this triggers a known bug with
+ // the soft keyboard not being shown for the started activity. Why, Android, why?
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(intent);
+
+ setResult(RESULT_CANCELED);
+ finish();
+ return;
+ }
+ statusFragment.refresh(fxAccount);
+ }
+
+ /**
+ * Helper to fetch (unique) Android Firefox Account if one exists, or return null.
+ */
+ protected AndroidFxAccount getAndroidFxAccount() {
+ Account account = FirefoxAccounts.getFirefoxAccount(this);
+ if (account == null) {
+ return null;
+ }
+ return new AndroidFxAccount(this, account);
+ }
+
+
+ /**
+ * Helper function to maybe remove the given Android account.
+ */
+ @SuppressLint("InlinedApi")
+ public static void maybeDeleteAndroidAccount(final Activity activity, final Account account, final Intent intent) {
+ if (account == null) {
+ Logger.warn(LOG_TAG, "Trying to delete null account; ignoring request.");
+ return;
+ }
+
+ final AccountManagerCallback<Boolean> callback = new AccountManagerCallback<Boolean>() {
+ @Override
+ public void run(AccountManagerFuture<Boolean> future) {
+ Logger.info(LOG_TAG, "Account " + Utils.obfuscateEmail(account.name) + " removed.");
+ final String text = activity.getResources().getString(R.string.fxaccount_remove_account_toast, account.name);
+ Toast.makeText(activity, text, Toast.LENGTH_LONG).show();
+ if (intent != null) {
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ activity.startActivity(intent);
+ }
+ activity.finish();
+ }
+ };
+
+ /*
+ * Get the best dialog icon from the theme on v11+.
+ * See http://stackoverflow.com/questions/14910536/android-dialog-theme-makes-icon-too-light/14910945#14910945.
+ */
+ final int icon;
+ final TypedValue typedValue = new TypedValue();
+ activity.getTheme().resolveAttribute(android.R.attr.alertDialogIcon, typedValue, true);
+ icon = typedValue.resourceId;
+
+ final AlertDialog dialog = new AlertDialog.Builder(activity)
+ .setTitle(R.string.fxaccount_remove_account_dialog_title)
+ .setIcon(icon)
+ .setMessage(R.string.fxaccount_remove_account_dialog_message)
+ .setPositiveButton(android.R.string.ok, new Dialog.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ AccountManager.get(activity).removeAccount(account, callback, null);
+ }
+ })
+ .setNegativeButton(android.R.string.cancel, new Dialog.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.cancel();
+ }
+ })
+ .create();
+
+ dialog.show();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ finish();
+ return true;
+ }
+
+ if (itemId == R.id.enable_debug_mode) {
+ FxAccountUtils.LOG_PERSONAL_INFORMATION = !FxAccountUtils.LOG_PERSONAL_INFORMATION;
+ Toast.makeText(this, (FxAccountUtils.LOG_PERSONAL_INFORMATION ? "Enabled" : "Disabled") +
+ " Firefox Account personal information!", Toast.LENGTH_LONG).show();
+ item.setChecked(!item.isChecked());
+ // Display or hide debug options.
+ statusFragment.hardRefresh();
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ final MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.fxaccount_status_menu, menu);
+ // !defined(MOZILLA_OFFICIAL) || defined(NIGHTLY_BUILD) || defined(MOZ_DEBUG)
+ boolean enabled = !AppConstants.MOZILLA_OFFICIAL || AppConstants.NIGHTLY_BUILD || AppConstants.DEBUG_BUILD;
+ if (!enabled) {
+ menu.removeItem(R.id.enable_debug_mode);
+ } else {
+ final MenuItem debugModeItem = menu.findItem(R.id.enable_debug_mode);
+ if (debugModeItem != null) {
+ // Update checked state based on internal flag.
+ menu.findItem(R.id.enable_debug_mode).setChecked(FxAccountUtils.LOG_PERSONAL_INFORMATION);
+ }
+ }
+ return super.onCreateOptionsMenu(menu);
+ };
+
+ @Override
+ public void openOptionsMenu() {
+ // This is a workaround of an Android bug:
+ // https://code.google.com/p/android/issues/detail?id=185217
+ // openOptionsMenu isn't overriden by WindowDecorActionBar, which is used by AppCompatActivity,
+ // meaning getSupportActionbar().openOptionsMenu doesn't work.
+ // Based loosely on the code in:
+ // http://androidxref.com/6.0.1_r10/xref/frameworks/support/v7/appcompat/src/android/support/v7/internal/app/WindowDecorActionBar.java#getDecorToolbar
+
+ final Window window = getWindow();
+ final View decor = window.getDecorView();
+ final View view = decor.findViewById(R.id.action_bar);
+
+ if (view instanceof Toolbar) {
+ final Toolbar toolbar = (Toolbar) view;
+ toolbar.showOverflowMenu();
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusFragment.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusFragment.java
new file mode 100644
index 000000000..a30b92e5f
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountStatusFragment.java
@@ -0,0 +1,949 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.activities;
+
+import android.accounts.Account;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.os.Handler;
+import android.preference.CheckBoxPreference;
+import android.preference.EditTextPreference;
+import android.preference.Preference;
+import android.preference.Preference.OnPreferenceChangeListener;
+import android.preference.Preference.OnPreferenceClickListener;
+import android.preference.PreferenceCategory;
+import android.preference.PreferenceScreen;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+
+import com.squareup.picasso.Picasso;
+import com.squareup.picasso.Target;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.background.preferences.PreferenceFragment;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.SyncStatusListener;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.Married;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.sync.FxAccountSyncStatusHelper;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.setup.activities.ActivityUtils;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A fragment that displays the status of an AndroidFxAccount.
+ * <p>
+ * The owning activity is responsible for providing an AndroidFxAccount at
+ * appropriate times.
+ */
+public class FxAccountStatusFragment
+ extends PreferenceFragment
+ implements OnPreferenceClickListener, OnPreferenceChangeListener {
+ private static final String LOG_TAG = FxAccountStatusFragment.class.getSimpleName();
+
+ /**
+ * If a device claims to have synced before this date, we will assume it has never synced.
+ */
+ private static final Date EARLIEST_VALID_SYNCED_DATE;
+
+ static {
+ final Calendar c = GregorianCalendar.getInstance();
+ c.set(2000, Calendar.JANUARY, 1, 0, 0, 0);
+ EARLIEST_VALID_SYNCED_DATE = c.getTime();
+ }
+
+ // When a checkbox is toggled, wait 5 seconds (for other checkbox actions)
+ // before trying to sync. Should we kill off the fragment before the sync
+ // request happens, that's okay: the runnable will run if the UI thread is
+ // still around to service it, and since we're not updating any UI, we'll just
+ // schedule the sync as usual. See also comment below about garbage
+ // collection.
+ private static final long DELAY_IN_MILLISECONDS_BEFORE_REQUESTING_SYNC = 5 * 1000;
+ private static final long LAST_SYNCED_TIME_UPDATE_INTERVAL_IN_MILLISECONDS = 60 * 1000;
+ private static final long PROFILE_FETCH_RETRY_INTERVAL_IN_MILLISECONDS = 60 * 1000;
+
+ private static final String[] STAGES_TO_SYNC_ON_DEVICE_NAME_CHANGE = new String[] { "clients" };
+
+ // By default, the auth/account server preference is only shown when the
+ // account is configured to use a custom server. In debug mode, this is set.
+ private static boolean ALWAYS_SHOW_AUTH_SERVER = false;
+
+ // By default, the Sync server preference is only shown when the account is
+ // configured to use a custom Sync server. In debug mode, this is set.
+ private static boolean ALWAYS_SHOW_SYNC_SERVER = false;
+
+ protected PreferenceCategory accountCategory;
+ protected Preference profilePreference;
+ protected Preference manageAccountPreference;
+ protected Preference authServerPreference;
+ protected Preference removeAccountPreference;
+
+ protected Preference needsPasswordPreference;
+ protected Preference needsUpgradePreference;
+ protected Preference needsVerificationPreference;
+ protected Preference needsMasterSyncAutomaticallyEnabledPreference;
+ protected Preference needsFinishMigratingPreference;
+
+ protected PreferenceCategory syncCategory;
+
+ protected CheckBoxPreference bookmarksPreference;
+ protected CheckBoxPreference historyPreference;
+ protected CheckBoxPreference tabsPreference;
+ protected CheckBoxPreference passwordsPreference;
+ protected CheckBoxPreference readingListPreference;
+
+ protected EditTextPreference deviceNamePreference;
+ protected Preference syncServerPreference;
+ protected Preference morePreference;
+ protected Preference syncNowPreference;
+
+ protected volatile AndroidFxAccount fxAccount;
+ // The contract is: when fxAccount is non-null, then clientsDataDelegate is
+ // non-null. If violated then an IllegalStateException is thrown.
+ protected volatile SharedPreferencesClientsDataDelegate clientsDataDelegate;
+
+ // Used to post delayed sync requests.
+ protected Handler handler;
+
+ // Member variable so that re-posting pushes back the already posted instance.
+ // This Runnable references the fxAccount above, but it is not specific to a
+ // single account. (That is, it does not capture a single account instance.)
+ protected Runnable requestSyncRunnable;
+
+ // Runnable to update last synced time.
+ protected Runnable lastSyncedTimeUpdateRunnable;
+
+ // Broadcast Receiver to update profile Information.
+ protected FxAccountProfileInformationReceiver accountProfileInformationReceiver;
+
+ protected final InnerSyncStatusDelegate syncStatusDelegate = new InnerSyncStatusDelegate();
+ private Target profileAvatarTarget;
+
+ protected Preference ensureFindPreference(String key) {
+ Preference preference = findPreference(key);
+ if (preference == null) {
+ throw new IllegalStateException("Could not find preference with key: " + key);
+ }
+ return preference;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // We need to do this before we can query the hardware menu button state.
+ // We're guaranteed to have an activity at this point (onAttach is called
+ // before onCreate). It's okay to call this multiple times (with different
+ // contexts).
+ HardwareUtils.init(getActivity());
+
+ addPreferences();
+ }
+
+ protected void addPreferences() {
+ addPreferencesFromResource(R.xml.fxaccount_status_prefscreen);
+
+ accountCategory = (PreferenceCategory) ensureFindPreference("signed_in_as_category");
+ profilePreference = ensureFindPreference("profile");
+ manageAccountPreference = ensureFindPreference("manage_account");
+ authServerPreference = ensureFindPreference("auth_server");
+ removeAccountPreference = ensureFindPreference("remove_account");
+
+ needsPasswordPreference = ensureFindPreference("needs_credentials");
+ needsUpgradePreference = ensureFindPreference("needs_upgrade");
+ needsVerificationPreference = ensureFindPreference("needs_verification");
+ needsMasterSyncAutomaticallyEnabledPreference = ensureFindPreference("needs_master_sync_automatically_enabled");
+ needsFinishMigratingPreference = ensureFindPreference("needs_finish_migrating");
+
+ syncCategory = (PreferenceCategory) ensureFindPreference("sync_category");
+
+ bookmarksPreference = (CheckBoxPreference) ensureFindPreference("bookmarks");
+ historyPreference = (CheckBoxPreference) ensureFindPreference("history");
+ tabsPreference = (CheckBoxPreference) ensureFindPreference("tabs");
+ passwordsPreference = (CheckBoxPreference) ensureFindPreference("passwords");
+
+ if (!FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ removeDebugButtons();
+ } else {
+ connectDebugButtons();
+ ALWAYS_SHOW_AUTH_SERVER = true;
+ ALWAYS_SHOW_SYNC_SERVER = true;
+ }
+
+ profilePreference.setOnPreferenceClickListener(this);
+ manageAccountPreference.setOnPreferenceClickListener(this);
+ removeAccountPreference.setOnPreferenceClickListener(this);
+
+ needsPasswordPreference.setOnPreferenceClickListener(this);
+ needsVerificationPreference.setOnPreferenceClickListener(this);
+ needsFinishMigratingPreference.setOnPreferenceClickListener(this);
+
+ bookmarksPreference.setOnPreferenceClickListener(this);
+ historyPreference.setOnPreferenceClickListener(this);
+ tabsPreference.setOnPreferenceClickListener(this);
+ passwordsPreference.setOnPreferenceClickListener(this);
+
+ deviceNamePreference = (EditTextPreference) ensureFindPreference("device_name");
+ deviceNamePreference.setOnPreferenceChangeListener(this);
+
+ syncServerPreference = ensureFindPreference("sync_server");
+ morePreference = ensureFindPreference("more");
+ morePreference.setOnPreferenceClickListener(this);
+
+ syncNowPreference = ensureFindPreference("sync_now");
+ syncNowPreference.setEnabled(true);
+ syncNowPreference.setOnPreferenceClickListener(this);
+
+ ensureFindPreference("linktos").setOnPreferenceClickListener(this);
+ ensureFindPreference("linkprivacy").setOnPreferenceClickListener(this);
+ }
+
+ /**
+ * We intentionally don't refresh here. Our owning activity is responsible for
+ * providing an AndroidFxAccount to our refresh method in its onResume method.
+ */
+ @Override
+ public void onResume() {
+ super.onResume();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ if (preference == profilePreference) {
+ ActivityUtils.openURLInFennec(getActivity().getApplicationContext(), "about:accounts?action=avatar");
+ return true;
+ }
+
+ if (preference == manageAccountPreference) {
+ ActivityUtils.openURLInFennec(getActivity().getApplicationContext(), "about:accounts?action=manage");
+ return true;
+ }
+
+ if (preference == removeAccountPreference) {
+ FxAccountStatusActivity.maybeDeleteAndroidAccount(getActivity(), fxAccount.getAndroidAccount(), null);
+ return true;
+ }
+
+ if (preference == needsPasswordPreference) {
+ final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_UPDATE_CREDENTIALS);
+ intent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_PREFERENCES);
+ // Per http://stackoverflow.com/a/8992365, this triggers a known bug with
+ // the soft keyboard not being shown for the started activity. Why, Android, why?
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(intent);
+
+ return true;
+ }
+
+ if (preference == needsFinishMigratingPreference) {
+ final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_FINISH_MIGRATING);
+ intent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_PREFERENCES);
+ // Per http://stackoverflow.com/a/8992365, this triggers a known bug with
+ // the soft keyboard not being shown for the started activity. Why, Android, why?
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(intent);
+
+ return true;
+ }
+
+ if (preference == needsVerificationPreference) {
+ final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_CONFIRM_ACCOUNT);
+ // Per http://stackoverflow.com/a/8992365, this triggers a known bug with
+ // the soft keyboard not being shown for the started activity. Why, Android, why?
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ intent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_PREFERENCES);
+ startActivity(intent);
+
+ return true;
+ }
+
+ if (preference == bookmarksPreference ||
+ preference == historyPreference ||
+ preference == passwordsPreference ||
+ preference == tabsPreference) {
+ saveEngineSelections();
+ return true;
+ }
+
+ if (preference == morePreference) {
+ getActivity().openOptionsMenu();
+ return true;
+ }
+
+ if (preference == syncNowPreference) {
+ if (fxAccount != null) {
+ fxAccount.requestImmediateSync(null, null);
+ }
+ return true;
+ }
+
+ if (TextUtils.equals("linktos", preference.getKey())) {
+ ActivityUtils.openURLInFennec(getActivity().getApplicationContext(), getResources().getString(R.string.fxaccount_link_tos));
+ return true;
+ }
+
+ if (TextUtils.equals("linkprivacy", preference.getKey())) {
+ ActivityUtils.openURLInFennec(getActivity().getApplicationContext(), getResources().getString(R.string.fxaccount_link_pn));
+ return true;
+ }
+
+ return false;
+ }
+
+ protected void setCheckboxesEnabled(boolean enabled) {
+ bookmarksPreference.setEnabled(enabled);
+ historyPreference.setEnabled(enabled);
+ tabsPreference.setEnabled(enabled);
+ passwordsPreference.setEnabled(enabled);
+ // Since we can't sync, we can't update our remote client record.
+ deviceNamePreference.setEnabled(enabled);
+ syncNowPreference.setEnabled(enabled);
+ }
+
+ /**
+ * Show at most one error preference, hiding all others.
+ *
+ * @param errorPreferenceToShow
+ * single error preference to show; if null, hide all error preferences
+ */
+ protected void showOnlyOneErrorPreference(Preference errorPreferenceToShow) {
+ final Preference[] errorPreferences = new Preference[] {
+ this.needsPasswordPreference,
+ this.needsUpgradePreference,
+ this.needsVerificationPreference,
+ this.needsMasterSyncAutomaticallyEnabledPreference,
+ this.needsFinishMigratingPreference,
+ };
+ for (Preference errorPreference : errorPreferences) {
+ final boolean currentlyShown = null != findPreference(errorPreference.getKey());
+ final boolean shouldBeShown = errorPreference == errorPreferenceToShow;
+ if (currentlyShown == shouldBeShown) {
+ continue;
+ }
+ if (shouldBeShown) {
+ syncCategory.addPreference(errorPreference);
+ } else {
+ syncCategory.removePreference(errorPreference);
+ }
+ }
+ }
+
+ protected void showNeedsPassword() {
+ syncCategory.setTitle(R.string.fxaccount_status_sync);
+ showOnlyOneErrorPreference(needsPasswordPreference);
+ setCheckboxesEnabled(false);
+ }
+
+ protected void showNeedsUpgrade() {
+ syncCategory.setTitle(R.string.fxaccount_status_sync);
+ showOnlyOneErrorPreference(needsUpgradePreference);
+ setCheckboxesEnabled(false);
+ }
+
+ protected void showNeedsVerification() {
+ syncCategory.setTitle(R.string.fxaccount_status_sync);
+ showOnlyOneErrorPreference(needsVerificationPreference);
+ setCheckboxesEnabled(false);
+ }
+
+ protected void showNeedsMasterSyncAutomaticallyEnabled() {
+ syncCategory.setTitle(R.string.fxaccount_status_sync);
+ needsMasterSyncAutomaticallyEnabledPreference.setTitle(AppConstants.Versions.preLollipop ?
+ R.string.fxaccount_status_needs_master_sync_automatically_enabled :
+ R.string.fxaccount_status_needs_master_sync_automatically_enabled_v21);
+ showOnlyOneErrorPreference(needsMasterSyncAutomaticallyEnabledPreference);
+ setCheckboxesEnabled(false);
+ }
+
+ protected void showNeedsFinishMigrating() {
+ syncCategory.setTitle(R.string.fxaccount_status_sync);
+ showOnlyOneErrorPreference(needsFinishMigratingPreference);
+ setCheckboxesEnabled(false);
+ }
+
+ protected void showConnected() {
+ syncCategory.setTitle(R.string.fxaccount_status_sync_enabled);
+ showOnlyOneErrorPreference(null);
+ setCheckboxesEnabled(true);
+ }
+
+ protected class InnerSyncStatusDelegate implements SyncStatusListener {
+ protected final Runnable refreshRunnable = new Runnable() {
+ @Override
+ public void run() {
+ refresh();
+ }
+ };
+
+ @Override
+ public Context getContext() {
+ return FxAccountStatusFragment.this.getActivity();
+ }
+
+ @Override
+ public Account getAccount() {
+ return fxAccount.getAndroidAccount();
+ }
+
+ @Override
+ public void onSyncStarted() {
+ if (fxAccount == null) {
+ return;
+ }
+ Logger.info(LOG_TAG, "Got sync started message; refreshing.");
+ getActivity().runOnUiThread(refreshRunnable);
+ }
+
+ @Override
+ public void onSyncFinished() {
+ if (fxAccount == null) {
+ return;
+ }
+ Logger.info(LOG_TAG, "Got sync finished message; refreshing.");
+ getActivity().runOnUiThread(refreshRunnable);
+ }
+ }
+
+ /**
+ * Notify the fragment that a new AndroidFxAccount instance is current.
+ * <p>
+ * <b>Important:</b> call this method on the UI thread!
+ * <p>
+ * In future, this might be a Loader.
+ *
+ * @param fxAccount new instance.
+ */
+ public void refresh(AndroidFxAccount fxAccount) {
+ if (fxAccount == null) {
+ throw new IllegalArgumentException("fxAccount must not be null");
+ }
+ this.fxAccount = fxAccount;
+ try {
+ this.clientsDataDelegate = new SharedPreferencesClientsDataDelegate(fxAccount.getSyncPrefs(), getActivity().getApplicationContext());
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Got exception fetching Sync prefs associated to Firefox Account; aborting.", e);
+ // Something is terribly wrong; best to get a stack trace rather than
+ // continue with a null clients delegate.
+ throw new IllegalStateException(e);
+ }
+
+ handler = new Handler(); // Attached to current (assumed to be UI) thread.
+
+ // Runnable is not specific to one Firefox Account. This runnable will keep
+ // a reference to this fragment alive, but we expect posted runnables to be
+ // serviced very quickly, so this is not an issue.
+ requestSyncRunnable = new RequestSyncRunnable();
+ lastSyncedTimeUpdateRunnable = new LastSyncTimeUpdateRunnable();
+
+ // We would very much like register these status observers in bookended
+ // onResume/onPause calls, but because the Fragment gets onResume during the
+ // Activity's super.onResume, it hasn't yet been told its Firefox Account.
+ // So we register the observer here (and remove it in onPause), and open
+ // ourselves to the possibility that we don't have properly paired
+ // register/unregister calls.
+ FxAccountSyncStatusHelper.getInstance().startObserving(syncStatusDelegate);
+
+ // Register a local broadcast receiver to get profile cached notification.
+ final IntentFilter intentFilter = new IntentFilter();
+ intentFilter.addAction(FxAccountConstants.ACCOUNT_PROFILE_JSON_UPDATED_ACTION);
+ accountProfileInformationReceiver = new FxAccountProfileInformationReceiver();
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(accountProfileInformationReceiver, intentFilter);
+
+ // profilePreference is set during onCreate, so it's definitely not null here.
+ final float cornerRadius = getResources().getDimension(R.dimen.fxaccount_profile_image_width) / 2;
+ profileAvatarTarget = new PicassoPreferenceIconTarget(getResources(), profilePreference, cornerRadius);
+
+ refresh();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ FxAccountSyncStatusHelper.getInstance().stopObserving(syncStatusDelegate);
+
+ // Focus lost, remove scheduled update if any.
+ if (lastSyncedTimeUpdateRunnable != null) {
+ handler.removeCallbacks(lastSyncedTimeUpdateRunnable);
+ }
+
+ // Focus lost, unregister broadcast receiver.
+ if (accountProfileInformationReceiver != null) {
+ LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(accountProfileInformationReceiver);
+ }
+
+ if (profileAvatarTarget != null) {
+ Picasso.with(getActivity()).cancelRequest(profileAvatarTarget);
+ profileAvatarTarget = null;
+ }
+ }
+
+ protected void hardRefresh() {
+ // This is the only way to guarantee that the EditText dialogs created by
+ // EditTextPreferences are re-created. This works around the issue described
+ // at http://androiddev.orkitra.com/?p=112079.
+ final PreferenceScreen statusScreen = (PreferenceScreen) ensureFindPreference("status_screen");
+ statusScreen.removeAll();
+ addPreferences();
+
+ refresh();
+ }
+
+ protected void refresh() {
+ // refresh is called from our onResume, which can happen before the owning
+ // Activity tells us about an account (via our public
+ // refresh(AndroidFxAccount) method).
+ if (fxAccount == null) {
+ throw new IllegalArgumentException("fxAccount must not be null");
+ }
+
+ updateProfileInformation();
+ updateAuthServerPreference();
+ updateSyncServerPreference();
+
+ try {
+ // There are error states determined by Android, not the login state
+ // machine, and we have a chance to present these states here. We handle
+ // them specially, since we can't surface these states as part of syncing,
+ // because they generally stop syncs from happening regularly. Right now
+ // there are no such states.
+
+ // Interrogate the Firefox Account's state.
+ State state = fxAccount.getState();
+ switch (state.getNeededAction()) {
+ case NeedsUpgrade:
+ showNeedsUpgrade();
+ break;
+ case NeedsPassword:
+ showNeedsPassword();
+ break;
+ case NeedsVerification:
+ showNeedsVerification();
+ break;
+ case NeedsFinishMigrating:
+ showNeedsFinishMigrating();
+ break;
+ case None:
+ showConnected();
+ break;
+ }
+
+ // We check for the master setting last, since it is not strictly
+ // necessary for the user to address this error state: it's really a
+ // warning state. We surface it for the user's convenience, and to prevent
+ // confused folks wondering why Sync is not working at all.
+ final boolean masterSyncAutomatically = ContentResolver.getMasterSyncAutomatically();
+ if (!masterSyncAutomatically) {
+ showNeedsMasterSyncAutomaticallyEnabled();
+ return;
+ }
+ } finally {
+ // No matter our state, we should update the checkboxes.
+ updateSelectedEngines();
+ }
+
+ final String clientName = clientsDataDelegate.getClientName();
+ deviceNamePreference.setSummary(clientName);
+ deviceNamePreference.setText(clientName);
+
+ updateSyncNowPreference();
+ }
+
+ // This is a helper function similar to TabsAccessor.getLastSyncedString() to calculate relative "Last synced" time span.
+ private String getLastSyncedString(final long startTime) {
+ if (new Date(startTime).before(EARLIEST_VALID_SYNCED_DATE)) {
+ return getActivity().getString(R.string.fxaccount_status_never_synced);
+ }
+ final CharSequence relativeTimeSpanString = DateUtils.getRelativeTimeSpanString(startTime);
+ return getActivity().getResources().getString(R.string.fxaccount_status_last_synced, relativeTimeSpanString);
+ }
+
+ protected void updateSyncNowPreference() {
+ final boolean currentlySyncing = fxAccount.isCurrentlySyncing();
+ syncNowPreference.setEnabled(!currentlySyncing);
+ if (currentlySyncing) {
+ syncNowPreference.setTitle(R.string.fxaccount_status_syncing);
+ } else {
+ syncNowPreference.setTitle(R.string.fxaccount_status_sync_now);
+ }
+ scheduleAndUpdateLastSyncedTime();
+ }
+
+ private void updateProfileInformation() {
+
+ final ExtendedJSONObject profileJSON = fxAccount.getProfileJSON();
+ if (profileJSON == null) {
+ // Update the profile title with email as the fallback.
+ // Profile icon by default use the default avatar as the fallback.
+ profilePreference.setTitle(fxAccount.getEmail());
+ return;
+ }
+
+ updateProfileInformation(profileJSON);
+ }
+
+ /**
+ * Update profile information from json on UI thread.
+ *
+ * @param profileJSON json fetched from server.
+ */
+ protected void updateProfileInformation(final ExtendedJSONObject profileJSON) {
+ // View changes must always be done on UI thread.
+ ThreadUtils.assertOnUiThread();
+
+ FxAccountUtils.pii(LOG_TAG, "Profile JSON is: " + profileJSON.toJSONString());
+
+ final String userName = profileJSON.getString(FxAccountConstants.KEY_PROFILE_JSON_USERNAME);
+ // Update the profile username and email if available.
+ if (!TextUtils.isEmpty(userName)) {
+ profilePreference.setTitle(userName);
+ profilePreference.setSummary(fxAccount.getEmail());
+ } else {
+ profilePreference.setTitle(fxAccount.getEmail());
+ }
+
+ // Avatar URI empty, skip profile image fetch.
+ final String avatarURI = profileJSON.getString(FxAccountConstants.KEY_PROFILE_JSON_AVATAR);
+ if (TextUtils.isEmpty(avatarURI)) {
+ Logger.info(LOG_TAG, "AvatarURI is empty, skipping profile image fetch.");
+ return;
+ }
+
+ // Using noPlaceholder would avoid a pop of the default image, but it's not available in the version of Picasso
+ // we ship in the tree.
+ Picasso
+ .with(getActivity())
+ .load(avatarURI)
+ .centerInside()
+ .resizeDimen(R.dimen.fxaccount_profile_image_width, R.dimen.fxaccount_profile_image_height)
+ .placeholder(R.drawable.sync_avatar_default)
+ .error(R.drawable.sync_avatar_default)
+ .into(profileAvatarTarget);
+ }
+
+ private void scheduleAndUpdateLastSyncedTime() {
+ final String lastSynced = getLastSyncedString(fxAccount.getLastSyncedTimestamp());
+ syncNowPreference.setSummary(lastSynced);
+ handler.postDelayed(lastSyncedTimeUpdateRunnable, LAST_SYNCED_TIME_UPDATE_INTERVAL_IN_MILLISECONDS);
+ }
+
+ protected void updateAuthServerPreference() {
+ final String authServer = fxAccount.getAccountServerURI();
+ final boolean shouldBeShown = ALWAYS_SHOW_AUTH_SERVER || !FxAccountConstants.DEFAULT_AUTH_SERVER_ENDPOINT.equals(authServer);
+ final boolean currentlyShown = null != findPreference(authServerPreference.getKey());
+ if (currentlyShown != shouldBeShown) {
+ if (shouldBeShown) {
+ accountCategory.addPreference(authServerPreference);
+ } else {
+ accountCategory.removePreference(authServerPreference);
+ }
+ }
+ // Always set the summary, because on first run, the preference is visible,
+ // and the above block will be skipped if there is a custom value.
+ authServerPreference.setSummary(authServer);
+ }
+
+ protected void updateSyncServerPreference() {
+ final String syncServer = fxAccount.getTokenServerURI();
+ final boolean shouldBeShown = ALWAYS_SHOW_SYNC_SERVER || !FxAccountConstants.DEFAULT_TOKEN_SERVER_ENDPOINT.equals(syncServer);
+ final boolean currentlyShown = null != findPreference(syncServerPreference.getKey());
+ if (currentlyShown != shouldBeShown) {
+ if (shouldBeShown) {
+ syncCategory.addPreference(syncServerPreference);
+ } else {
+ syncCategory.removePreference(syncServerPreference);
+ }
+ }
+ // Always set the summary, because on first run, the preference is visible,
+ // and the above block will be skipped if there is a custom value.
+ syncServerPreference.setSummary(syncServer);
+ }
+
+ /**
+ * Query shared prefs for the current engine state, and update the UI
+ * accordingly.
+ * <p>
+ * In future, we might want this to be on a background thread, or implemented
+ * as a Loader.
+ */
+ protected void updateSelectedEngines() {
+ try {
+ SharedPreferences syncPrefs = fxAccount.getSyncPrefs();
+ Map<String, Boolean> engines = SyncConfiguration.getUserSelectedEngines(syncPrefs);
+ if (engines != null) {
+ bookmarksPreference.setChecked(engines.containsKey("bookmarks") && engines.get("bookmarks"));
+ historyPreference.setChecked(engines.containsKey("history") && engines.get("history"));
+ passwordsPreference.setChecked(engines.containsKey("passwords") && engines.get("passwords"));
+ tabsPreference.setChecked(engines.containsKey("tabs") && engines.get("tabs"));
+ return;
+ }
+
+ // We don't have user specified preferences. Perhaps we have seen a meta/global?
+ Set<String> enabledNames = SyncConfiguration.getEnabledEngineNames(syncPrefs);
+ if (enabledNames != null) {
+ bookmarksPreference.setChecked(enabledNames.contains("bookmarks"));
+ historyPreference.setChecked(enabledNames.contains("history"));
+ passwordsPreference.setChecked(enabledNames.contains("passwords"));
+ tabsPreference.setChecked(enabledNames.contains("tabs"));
+ return;
+ }
+
+ // Okay, we don't have userSelectedEngines or enabledEngines. That means
+ // the user hasn't specified to begin with, we haven't specified here, and
+ // we haven't already seen, Sync engines. We don't know our state, so
+ // let's check everything (the default) and disable everything.
+ bookmarksPreference.setChecked(true);
+ historyPreference.setChecked(true);
+ passwordsPreference.setChecked(true);
+ tabsPreference.setChecked(true);
+ setCheckboxesEnabled(false);
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception getting engines to select; ignoring.", e);
+ return;
+ }
+ }
+
+ /**
+ * Persist engine selections to local shared preferences, and request a sync
+ * to persist selections to remote storage.
+ */
+ protected void saveEngineSelections() {
+ final Map<String, Boolean> engineSelections = new HashMap<String, Boolean>();
+ engineSelections.put("bookmarks", bookmarksPreference.isChecked());
+ engineSelections.put("history", historyPreference.isChecked());
+ engineSelections.put("passwords", passwordsPreference.isChecked());
+ engineSelections.put("tabs", tabsPreference.isChecked());
+
+ // No GlobalSession.config, so store directly to shared prefs. We do this on
+ // a background thread to avoid IO on the main thread and strict mode
+ // warnings.
+ new Thread(new PersistEngineSelectionsRunnable(engineSelections)).start();
+ }
+
+ protected void requestDelayedSync() {
+ Logger.info(LOG_TAG, "Posting a delayed request for a sync sometime soon.");
+ handler.removeCallbacks(requestSyncRunnable);
+ handler.postDelayed(requestSyncRunnable, DELAY_IN_MILLISECONDS_BEFORE_REQUESTING_SYNC);
+ }
+
+ /**
+ * Remove all traces of debug buttons. By default, no debug buttons are shown.
+ */
+ protected void removeDebugButtons() {
+ final PreferenceScreen statusScreen = (PreferenceScreen) ensureFindPreference("status_screen");
+ final PreferenceCategory debugCategory = (PreferenceCategory) ensureFindPreference("debug_category");
+ statusScreen.removePreference(debugCategory);
+ }
+
+ /**
+ * A Runnable that persists engine selections to shared prefs, and then
+ * requests a delayed sync.
+ * <p>
+ * References the member <code>fxAccount</code> and is specific to the Android
+ * account associated to that account.
+ */
+ protected class PersistEngineSelectionsRunnable implements Runnable {
+ private final Map<String, Boolean> engineSelections;
+
+ protected PersistEngineSelectionsRunnable(Map<String, Boolean> engineSelections) {
+ this.engineSelections = engineSelections;
+ }
+
+ @Override
+ public void run() {
+ try {
+ // Name shadowing -- do you like it, or do you love it?
+ AndroidFxAccount fxAccount = FxAccountStatusFragment.this.fxAccount;
+ if (fxAccount == null) {
+ return;
+ }
+ Logger.info(LOG_TAG, "Persisting engine selections: " + engineSelections.toString());
+ SyncConfiguration.storeSelectedEnginesToPrefs(fxAccount.getSyncPrefs(), engineSelections);
+ requestDelayedSync();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception persisting selected engines; ignoring.", e);
+ return;
+ }
+ }
+ }
+
+ /**
+ * A Runnable that requests a sync.
+ * <p>
+ * References the member <code>fxAccount</code>, but is not specific to the
+ * Android account associated to that account.
+ */
+ protected class RequestSyncRunnable implements Runnable {
+ @Override
+ public void run() {
+ // Name shadowing -- do you like it, or do you love it?
+ AndroidFxAccount fxAccount = FxAccountStatusFragment.this.fxAccount;
+ if (fxAccount == null) {
+ return;
+ }
+ Logger.info(LOG_TAG, "Requesting a sync sometime soon.");
+ fxAccount.requestEventualSync(null, null);
+ }
+ }
+
+ /**
+ * The Runnable that schedules a future update and updates the last synced time.
+ */
+ protected class LastSyncTimeUpdateRunnable implements Runnable {
+ @Override
+ public void run() {
+ scheduleAndUpdateLastSyncedTime();
+ }
+ }
+
+ /**
+ * Broadcast receiver to receive updates for the cached profile action.
+ */
+ public class FxAccountProfileInformationReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (!intent.getAction().equals(FxAccountConstants.ACCOUNT_PROFILE_JSON_UPDATED_ACTION)) {
+ return;
+ }
+
+ Logger.info(LOG_TAG, "Profile avatar cache update action broadcast received.");
+ // Update the UI from cached profile json on the main thread.
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ updateProfileInformation();
+ }
+ });
+ }
+ }
+
+ /**
+ * A separate listener to separate debug logic from main code paths.
+ */
+ protected class DebugPreferenceClickListener implements OnPreferenceClickListener {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ final String key = preference.getKey();
+ if ("debug_refresh".equals(key)) {
+ Logger.info(LOG_TAG, "Refreshing.");
+ refresh();
+ } else if ("debug_dump".equals(key)) {
+ fxAccount.dump();
+ } else if ("debug_force_sync".equals(key)) {
+ Logger.info(LOG_TAG, "Force syncing.");
+ fxAccount.requestImmediateSync(null, null);
+ // No sense refreshing, since the sync will complete in the future.
+ } else if ("debug_forget_certificate".equals(key)) {
+ State state = fxAccount.getState();
+ try {
+ Married married = (Married) state;
+ Logger.info(LOG_TAG, "Moving to Cohabiting state: Forgetting certificate.");
+ fxAccount.setState(married.makeCohabitingState());
+ refresh();
+ } catch (ClassCastException e) {
+ Logger.info(LOG_TAG, "Not in Married state; can't forget certificate.");
+ // Ignore.
+ }
+ } else if ("debug_invalidate_certificate".equals(key)) {
+ State state = fxAccount.getState();
+ try {
+ Married married = (Married) state;
+ Logger.info(LOG_TAG, "Invalidating certificate.");
+ fxAccount.setState(married.makeCohabitingState().withCertificate("INVALID CERTIFICATE"));
+ refresh();
+ } catch (ClassCastException e) {
+ Logger.info(LOG_TAG, "Not in Married state; can't invalidate certificate.");
+ // Ignore.
+ }
+ } else if ("debug_require_password".equals(key)) {
+ Logger.info(LOG_TAG, "Moving to Separated state: Forgetting password.");
+ State state = fxAccount.getState();
+ fxAccount.setState(state.makeSeparatedState());
+ refresh();
+ } else if ("debug_require_upgrade".equals(key)) {
+ Logger.info(LOG_TAG, "Moving to Doghouse state: Requiring upgrade.");
+ State state = fxAccount.getState();
+ fxAccount.setState(state.makeDoghouseState());
+ refresh();
+ } else if ("debug_migrated_from_sync11".equals(key)) {
+ Logger.info(LOG_TAG, "Moving to MigratedFromSync11 state: Requiring password.");
+ State state = fxAccount.getState();
+ fxAccount.setState(state.makeMigratedFromSync11State(null));
+ refresh();
+ } else if ("debug_make_account_stage".equals(key)) {
+ Logger.info(LOG_TAG, "Moving Account endpoints, in place, to stage. Deleting Sync and RL prefs and requiring password.");
+ fxAccount.unsafeTransitionToStageEndpoints();
+ refresh();
+ } else if ("debug_make_account_default".equals(key)) {
+ Logger.info(LOG_TAG, "Moving Account endpoints, in place, to default (production). Deleting Sync and RL prefs and requiring password.");
+ fxAccount.unsafeTransitionToDefaultEndpoints();
+ refresh();
+ } else {
+ return false;
+ }
+ return true;
+ }
+ }
+
+ /**
+ * Iterate through debug buttons, adding a special debug preference click
+ * listener to each of them.
+ */
+ protected void connectDebugButtons() {
+ // Separate listener to really separate debug logic from main code paths.
+ final OnPreferenceClickListener listener = new DebugPreferenceClickListener();
+
+ // We don't want to use Android resource strings for debug UI, so we just
+ // use the keys throughout.
+ final PreferenceCategory debugCategory = (PreferenceCategory) ensureFindPreference("debug_category");
+ debugCategory.setTitle(debugCategory.getKey());
+
+ for (int i = 0; i < debugCategory.getPreferenceCount(); i++) {
+ final Preference button = debugCategory.getPreference(i);
+ button.setTitle(button.getKey()); // Not very friendly, but this is for debugging only!
+ button.setOnPreferenceClickListener(listener);
+ }
+ }
+
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ if (preference == deviceNamePreference) {
+ String newClientName = (String) newValue;
+ if (TextUtils.isEmpty(newClientName)) {
+ newClientName = clientsDataDelegate.getDefaultClientName();
+ }
+ final long now = System.currentTimeMillis();
+ clientsDataDelegate.setClientName(newClientName, now);
+ // Force sync the client record, we want the user to see the device name change immediately
+ // on the FxA Device Manager if possible ( = we are online) to avoid confusion
+ // ("I changed my Android's device name but I don't see it on my computer").
+ fxAccount.requestImmediateSync(STAGES_TO_SYNC_ON_DEVICE_NAME_CHANGE, null);
+ hardRefresh(); // Updates the value displayed to the user, among other things.
+ return true;
+ }
+
+ // For everything else, accept the change.
+ return true;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountUpdateCredentialsActivityWeb.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountUpdateCredentialsActivityWeb.java
new file mode 100644
index 000000000..5a2ea79c8
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountUpdateCredentialsActivityWeb.java
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.activities;
+
+public class FxAccountUpdateCredentialsActivityWeb extends FxAccountWebFlowActivity {
+ public FxAccountUpdateCredentialsActivityWeb() {
+ super(CANNOT_RESUME_WHEN_NO_ACCOUNTS_EXIST, "force_auth");
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountWebFlowActivity.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountWebFlowActivity.java
new file mode 100644
index 000000000..e33e9c577
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/FxAccountWebFlowActivity.java
@@ -0,0 +1,91 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.activities;
+
+import android.content.Intent;
+import android.os.Bundle;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.sync.setup.activities.ActivityUtils;
+
+/**
+ * Activity which shows the status activity or passes through to web flow.
+ */
+public abstract class FxAccountWebFlowActivity extends FxAccountAbstractActivity {
+ protected static final String LOG_TAG = FxAccountWebFlowActivity.class.getSimpleName();
+
+ protected static final String ABOUT_ACCOUNTS = "about:accounts";
+
+ public static final String EXTRA_ENDPOINT = "entrypoint";
+
+ protected static final String[] EXTRAS_TO_PASSTHROUGH = new String[] {
+ EXTRA_ENDPOINT,
+ };
+
+ private final String action;
+ private final String extras;
+
+ public FxAccountWebFlowActivity(int resume, String action) {
+ this(resume, action, null);
+ }
+
+ public FxAccountWebFlowActivity(int resume, String action, String extras) {
+ super(resume);
+ this.action = action;
+ this.extras = (extras != null) ? ("&" + extras) : "";
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onCreate(Bundle icicle) {
+ Logger.setThreadLogTag(FxAccountConstants.GLOBAL_LOG_TAG);
+ Logger.debug(LOG_TAG, "onCreate(" + icicle + ")");
+
+ Locales.initializeLocale(getApplicationContext());
+
+ super.onCreate(icicle);
+ }
+
+ protected boolean redirectIfAppropriate() {
+ final boolean redirected = super.redirectIfAppropriate();
+ if (redirected) {
+ return true;
+ }
+
+ final StringBuilder sb = new StringBuilder();
+ sb.append(ABOUT_ACCOUNTS);
+ sb.append("?action=");
+ sb.append(action);
+ sb.append(extras);
+
+ // Pass through a set of known string values from intent extras to about:accounts.
+ final Intent intent = getIntent();
+ if (intent != null) {
+ for (String key : EXTRAS_TO_PASSTHROUGH) {
+ final String value = intent.getStringExtra(key);
+ if (value != null) {
+ sb.append("&");
+ sb.append(key);
+ sb.append("=");
+ sb.append(value);
+ }
+ }
+ }
+
+ ActivityUtils.openURLInFennec(getApplicationContext(), sb.toString());
+ return true;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ // We are always redirected.
+ this.finish();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/PicassoPreferenceIconTarget.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/PicassoPreferenceIconTarget.java
new file mode 100644
index 000000000..f71d3ed1c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/activities/PicassoPreferenceIconTarget.java
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.activities;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.preference.Preference;
+import android.support.v4.graphics.drawable.RoundedBitmapDrawable;
+import android.support.v4.graphics.drawable.RoundedBitmapDrawableFactory;
+import com.squareup.picasso.Picasso;
+import com.squareup.picasso.Target;
+import org.mozilla.gecko.AppConstants;
+
+/**
+ * A Picasso Target that updates a preference icon.
+ *
+ * Nota bene: Android grew support for updating preference icons programatically
+ * only in API 11. This class silently ignores requests before API 11.
+ */
+public class PicassoPreferenceIconTarget implements Target {
+ private final Preference preference;
+ private final Resources resources;
+ private final float cornerRadius;
+
+ public PicassoPreferenceIconTarget(Resources resources, Preference preference) {
+ this(resources, preference, 0);
+ }
+
+ public PicassoPreferenceIconTarget(Resources resources, Preference preference, float cornerRadius) {
+ this.resources = resources;
+ this.preference = preference;
+ this.cornerRadius = cornerRadius;
+ }
+
+ @Override
+ public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) {
+ final Drawable drawable;
+ if (cornerRadius > 0) {
+ final RoundedBitmapDrawable roundedBitmapDrawable;
+ roundedBitmapDrawable = RoundedBitmapDrawableFactory.create(resources, bitmap);
+ roundedBitmapDrawable.setCornerRadius(cornerRadius);
+ roundedBitmapDrawable.setAntiAlias(true);
+ drawable = roundedBitmapDrawable;
+ } else {
+ drawable = new BitmapDrawable(resources, bitmap);
+ }
+ preference.setIcon(drawable);
+ }
+
+ @Override
+ public void onBitmapFailed(Drawable errorDrawable) {
+ preference.setIcon(errorDrawable);
+ }
+
+ @Override
+ public void onPrepareLoad(Drawable placeHolderDrawable) {
+ preference.setIcon(placeHolderDrawable);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AccountPickler.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AccountPickler.java
new file mode 100644
index 000000000..3f2c5620d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AccountPickler.java
@@ -0,0 +1,362 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.authenticator;
+
+import java.io.FileOutputStream;
+import java.io.PrintStream;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.fxa.login.StateFactory;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.Utils;
+
+import android.content.Context;
+
+/**
+ * Android deletes Account objects when the Authenticator that owns the Account
+ * disappears. This happens when an App is installed to the SD card and the SD
+ * card is un-mounted or the device is rebooted.
+ * <p>
+ * We work around this by pickling the current Firefox account data every sync
+ * and unpickling when we check if Firefox accounts exist (called from Fennec).
+ * <p>
+ * Android just doesn't support installing Apps that define long-lived Services
+ * and/or own Account types onto the SD card. The documentation says not to do
+ * it. There are hordes of developers who want to do it, and have tried to
+ * register for almost every "package installation changed" broadcast intent
+ * that Android supports. They all explicitly state that the package that has
+ * changed does *not* receive the broadcast intent, thereby preventing an App
+ * from re-establishing its state.
+ * <p>
+ * <a href="http://developer.android.com/guide/topics/data/install-location.html">Reference.</a>
+ * <p>
+ * <b>Quote</b>: Your AbstractThreadedSyncAdapter and all its sync functionality
+ * will not work until external storage is remounted.
+ * <p>
+ * <b>Quote</b>: Your running Service will be killed and will not be restarted
+ * when external storage is remounted. You can, however, register for the
+ * ACTION_EXTERNAL_APPLICATIONS_AVAILABLE broadcast Intent, which will notify
+ * your application when applications installed on external storage have become
+ * available to the system again. At which time, you can restart your Service.
+ * <p>
+ * Problem: <a href="http://code.google.com/p/android/issues/detail?id=8485">that intent doesn't work</a>!
+ * <p>
+ * See bug 768102 for more information in the context of Sync.
+ */
+public class AccountPickler {
+ public static final String LOG_TAG = AccountPickler.class.getSimpleName();
+
+ public static final long PICKLE_VERSION = 3;
+
+ public static final String KEY_PICKLE_VERSION = "pickle_version";
+ public static final String KEY_PICKLE_TIMESTAMP = "pickle_timestamp";
+
+ public static final String KEY_ACCOUNT_VERSION = "account_version";
+ public static final String KEY_ACCOUNT_TYPE = "account_type";
+ public static final String KEY_EMAIL = "email";
+ public static final String KEY_PROFILE = "profile";
+ public static final String KEY_IDP_SERVER_URI = "idpServerURI";
+ public static final String KEY_TOKEN_SERVER_URI = "tokenServerURI";
+ public static final String KEY_PROFILE_SERVER_URI = "profileServerURI";
+
+ public static final String KEY_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP = "authoritiesToSyncAutomaticallyMap";
+
+ // Deprecated, but maintained for migration purposes.
+ public static final String KEY_IS_SYNCING_ENABLED = "isSyncingEnabled";
+
+ public static final String KEY_BUNDLE = "bundle";
+
+ /**
+ * Remove Firefox account persisted to disk.
+ * This operation is synchronized to avoid race condition while deleting the account.
+ *
+ * @param context Android context.
+ * @param filename name of persisted pickle file; must not contain path separators.
+ * @return <code>true</code> if given pickle existed and was successfully deleted.
+ */
+ public synchronized static boolean deletePickle(final Context context, final String filename) {
+ return context.deleteFile(filename);
+ }
+
+ public static ExtendedJSONObject toJSON(final AndroidFxAccount account, final long now) {
+ final ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put(KEY_PICKLE_VERSION, PICKLE_VERSION);
+ o.put(KEY_PICKLE_TIMESTAMP, now);
+
+ o.put(KEY_ACCOUNT_VERSION, AndroidFxAccount.CURRENT_ACCOUNT_VERSION);
+ o.put(KEY_ACCOUNT_TYPE, FxAccountConstants.ACCOUNT_TYPE);
+ o.put(KEY_EMAIL, account.getEmail());
+ o.put(KEY_PROFILE, account.getProfile());
+ o.put(KEY_IDP_SERVER_URI, account.getAccountServerURI());
+ o.put(KEY_TOKEN_SERVER_URI, account.getTokenServerURI());
+ o.put(KEY_PROFILE_SERVER_URI, account.getProfileServerURI());
+
+ final ExtendedJSONObject p = new ExtendedJSONObject();
+ for (Entry<String, Boolean> pair : account.getAuthoritiesToSyncAutomaticallyMap().entrySet()) {
+ p.put(pair.getKey(), pair.getValue());
+ }
+ o.put(KEY_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP, p);
+
+ // TODO: If prefs version changes under us, SyncPrefsPath will change, "clearing" prefs.
+
+ final ExtendedJSONObject bundle = account.unbundle();
+ if (bundle == null) {
+ Logger.warn(LOG_TAG, "Unable to obtain account bundle; aborting.");
+ return null;
+ }
+ o.put(KEY_BUNDLE, bundle);
+
+ return o;
+ }
+
+ /**
+ * Persist Firefox account to disk as a JSON object.
+ * This operation is synchronized to avoid race condition while deleting the account.
+ *
+ * @param account the AndroidFxAccount to persist to disk
+ * @param filename name of file to persist to; must not contain path separators.
+ */
+ public synchronized static void pickle(final AndroidFxAccount account, final String filename) {
+ final ExtendedJSONObject o = toJSON(account, System.currentTimeMillis());
+ writeToDisk(account.context, filename, o);
+ }
+
+ private static void writeToDisk(final Context context, final String filename,
+ final ExtendedJSONObject pickle) {
+ try {
+ final FileOutputStream fos = context.openFileOutput(filename, Context.MODE_PRIVATE);
+ try {
+ final PrintStream ps = new PrintStream(fos);
+ try {
+ ps.print(pickle.toJSONString());
+ Logger.debug(LOG_TAG, "Persisted " + pickle.keySet().size() +
+ " account settings to " + filename + ".");
+ } finally {
+ ps.close();
+ }
+ } finally {
+ fos.close();
+ }
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Caught exception persisting account settings to " + filename +
+ "; ignoring.", e);
+ }
+ }
+
+ /**
+ * Create Android account from saved JSON object. Assumes that an account does not exist.
+ * This operation is synchronized to avoid race condition while deleting the account.
+ *
+ * @param context
+ * Android context.
+ * @param filename
+ * name of file to read from; must not contain path separators.
+ * @return created Android account, or null on error.
+ */
+ public synchronized static AndroidFxAccount unpickle(final Context context, final String filename) {
+ final String jsonString = Utils.readFile(context, filename);
+ if (jsonString == null) {
+ Logger.info(LOG_TAG, "Pickle file '" + filename + "' not found; aborting.");
+ return null;
+ }
+
+ ExtendedJSONObject json = null;
+ try {
+ json = new ExtendedJSONObject(jsonString);
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception reading pickle file '" + filename + "'; aborting.", e);
+ return null;
+ }
+
+ final UnpickleParams params;
+ try {
+ params = UnpickleParams.fromJSON(json);
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception extracting unpickle json; aborting.", e);
+ return null;
+ }
+
+ final AndroidFxAccount account;
+ try {
+ account = AndroidFxAccount.addAndroidAccount(context, params.email, params.profile,
+ params.authServerURI, params.tokenServerURI, params.profileServerURI, params.state,
+ params.authoritiesToSyncAutomaticallyMap,
+ params.accountVersion,
+ true, params.bundle);
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Exception when adding Android Account; aborting.", e);
+ return null;
+ }
+
+ if (account == null) {
+ Logger.warn(LOG_TAG, "Failed to add Android Account; aborting.");
+ return null;
+ }
+
+ Long timestamp = json.getLong(KEY_PICKLE_TIMESTAMP);
+ if (timestamp == null) {
+ Logger.warn(LOG_TAG, "Did not find timestamp in pickle file; ignoring.");
+ timestamp = -1L;
+ }
+
+ Logger.info(LOG_TAG, "Un-pickled Android account named " + params.email + " (version " +
+ params.pickleVersion + ", pickled at " + timestamp + ").");
+
+ return account;
+ }
+
+ private static class UnpickleParams {
+ private Long pickleVersion;
+
+ private int accountVersion;
+ private String email;
+ private String profile;
+ private String authServerURI;
+ private String tokenServerURI;
+ private String profileServerURI;
+ private final Map<String, Boolean> authoritiesToSyncAutomaticallyMap = new HashMap<>();
+
+ private ExtendedJSONObject bundle;
+ private State state;
+
+ private UnpickleParams() {
+ }
+
+ private static UnpickleParams fromJSON(final ExtendedJSONObject json)
+ throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException {
+ final UnpickleParams params = new UnpickleParams();
+ params.pickleVersion = json.getLong(KEY_PICKLE_VERSION);
+ if (params.pickleVersion == null) {
+ throw new IllegalStateException("Pickle version not found.");
+ }
+
+ /*
+ * Version 1 and version 2 are identical, except version 2 throws if the
+ * internal Android Account type has changed. Version 1 used to throw in
+ * this case, but we intentionally used the pickle file to migrate across
+ * Account types, bumping the version simultaneously.
+ *
+ * Version 3 replaces "isSyncEnabled" with a map (String -> Boolean)
+ * associating Android authorities to whether or not they are configured
+ * to sync automatically.
+ */
+ switch (params.pickleVersion.intValue()) {
+ case 3: {
+ // Sanity check.
+ final String accountType = json.getString(KEY_ACCOUNT_TYPE);
+ if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) {
+ throw new IllegalStateException("Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + ".");
+ }
+
+ params.unpickleV3(json);
+ }
+ break;
+
+ case 2: {
+ // Sanity check.
+ final String accountType = json.getString(KEY_ACCOUNT_TYPE);
+ if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) {
+ throw new IllegalStateException("Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + ".");
+ }
+
+ params.unpickleV1(json);
+ }
+ break;
+
+ case 1: {
+ // Warn about account type changing, but don't throw over it.
+ final String accountType = json.getString(KEY_ACCOUNT_TYPE);
+ if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) {
+ Logger.warn(LOG_TAG, "Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + "; ignoring.");
+ }
+
+ params.unpickleV1(json);
+ }
+ break;
+
+ default:
+ throw new IllegalStateException("Unknown pickle version, " + params.pickleVersion + ".");
+ }
+
+ return params;
+ }
+
+ private void unpickleV1(final ExtendedJSONObject json)
+ throws NonObjectJSONException, NoSuchAlgorithmException, InvalidKeySpecException {
+
+ this.accountVersion = json.getIntegerSafely(KEY_ACCOUNT_VERSION);
+ this.email = json.getString(KEY_EMAIL);
+ this.profile = json.getString(KEY_PROFILE);
+ this.authServerURI = json.getString(KEY_IDP_SERVER_URI);
+ this.tokenServerURI = json.getString(KEY_TOKEN_SERVER_URI);
+ this.profileServerURI = json.getString(KEY_PROFILE_SERVER_URI);
+
+ // Fallback to default value when profile server URI was not pickled.
+ if (this.profileServerURI == null) {
+ this.profileServerURI = FxAccountConstants.DEFAULT_AUTH_SERVER_ENDPOINT.equals(this.authServerURI)
+ ? FxAccountConstants.DEFAULT_PROFILE_SERVER_ENDPOINT
+ : FxAccountConstants.STAGE_PROFILE_SERVER_ENDPOINT;
+ }
+
+ // We get the default value for everything except syncing browser data.
+ this.authoritiesToSyncAutomaticallyMap.put(BrowserContract.AUTHORITY, json.getBoolean(KEY_IS_SYNCING_ENABLED));
+
+ this.bundle = json.getObject(KEY_BUNDLE);
+ if (bundle == null) {
+ throw new IllegalStateException("Pickle bundle is null.");
+ }
+ this.state = getState(bundle);
+ }
+
+ private void unpickleV3(final ExtendedJSONObject json)
+ throws NonObjectJSONException, NoSuchAlgorithmException, InvalidKeySpecException {
+ // We'll overwrite the extracted sync automatically map.
+ unpickleV1(json);
+
+ // Extract the map of authorities to sync automatically.
+ authoritiesToSyncAutomaticallyMap.clear();
+ final ExtendedJSONObject o = json.getObject(KEY_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP);
+ if (o == null) {
+ return;
+ }
+ for (String key : o.keySet()) {
+ final Boolean enabled = o.getBoolean(key);
+ if (enabled != null) {
+ authoritiesToSyncAutomaticallyMap.put(key, enabled);
+ }
+ }
+ }
+
+ private State getState(final ExtendedJSONObject bundle) throws InvalidKeySpecException,
+ NonObjectJSONException, NoSuchAlgorithmException {
+ // TODO: Should copy-pasta BUNDLE_KEY_STATE & LABEL to this file to ensure we maintain
+ // old versions?
+ final StateLabel stateLabelString = StateLabel.valueOf(
+ bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE_LABEL));
+ final String stateString = bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE);
+ if (stateLabelString == null || stateString == null) {
+ throw new IllegalStateException("stateLabel and stateString must not be null, but: " +
+ "(stateLabel == null) = " + (stateLabelString == null) +
+ " and (stateString == null) = " + (stateString == null));
+ }
+
+ try {
+ return StateFactory.fromJSONObject(stateLabelString, new ExtendedJSONObject(stateString));
+ } catch (Exception e) {
+ throw new IllegalStateException("could not get state", e);
+ }
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
new file mode 100644
index 000000000..d7ce7c47f
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
@@ -0,0 +1,929 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.authenticator;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.ResultReceiver;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.fxa.login.StateFactory;
+import org.mozilla.gecko.fxa.login.TokensAndKeysState;
+import org.mozilla.gecko.fxa.sync.FxAccountProfileService;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.setup.Constants;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.util.ArrayList;
+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.Semaphore;
+
+/**
+ * A Firefox Account that stores its details and state as user data attached to
+ * an Android Account instance.
+ * <p>
+ * Account user data is accessible only to the Android App(s) that own the
+ * Account type. Account user data is not removed when the App's private data is
+ * cleared.
+ */
+public class AndroidFxAccount {
+ protected static final String LOG_TAG = AndroidFxAccount.class.getSimpleName();
+
+ public static final int CURRENT_SYNC_PREFS_VERSION = 1;
+ public static final int CURRENT_RL_PREFS_VERSION = 1;
+
+ // When updating the account, do not forget to update AccountPickler.
+ public static final int CURRENT_ACCOUNT_VERSION = 3;
+ public static final String ACCOUNT_KEY_ACCOUNT_VERSION = "version";
+ public static final String ACCOUNT_KEY_PROFILE = "profile";
+ public static final String ACCOUNT_KEY_IDP_SERVER = "idpServerURI";
+ private static final String ACCOUNT_KEY_PROFILE_SERVER = "profileServerURI";
+
+ public static final String ACCOUNT_KEY_TOKEN_SERVER = "tokenServerURI"; // Sync-specific.
+ public static final String ACCOUNT_KEY_DESCRIPTOR = "descriptor";
+
+ public static final int CURRENT_BUNDLE_VERSION = 2;
+ public static final String BUNDLE_KEY_BUNDLE_VERSION = "version";
+ public static final String BUNDLE_KEY_STATE_LABEL = "stateLabel";
+ public static final String BUNDLE_KEY_STATE = "state";
+ public static final String BUNDLE_KEY_PROFILE_JSON = "profile";
+
+ public static final String ACCOUNT_KEY_DEVICE_ID = "deviceId";
+ public static final String ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION = "deviceRegistrationVersion";
+
+ // Account authentication token type for fetching account profile.
+ public static final String PROFILE_OAUTH_TOKEN_TYPE = "oauth::profile";
+
+ // Services may request OAuth tokens from the Firefox Account dynamically.
+ // Each such token is prefixed with "oauth::" and a service-dependent scope.
+ // Such tokens should be destroyed when the account is removed from the device.
+ // This list collects all the known "oauth::" token types in order to delete them when necessary.
+ private static final List<String> KNOWN_OAUTH_TOKEN_TYPES;
+
+ static {
+ final List<String> list = new ArrayList<>();
+ list.add(PROFILE_OAUTH_TOKEN_TYPE);
+ KNOWN_OAUTH_TOKEN_TYPES = Collections.unmodifiableList(list);
+ }
+
+ public static final Map<String, Boolean> DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP;
+ static {
+ final HashMap<String, Boolean> m = new HashMap<String, Boolean>();
+ // By default, Firefox Sync is enabled.
+ m.put(BrowserContract.AUTHORITY, true);
+ DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP = Collections.unmodifiableMap(m);
+ }
+
+ private static final String PREF_KEY_LAST_SYNCED_TIMESTAMP = "lastSyncedTimestamp";
+
+ protected final Context context;
+ protected final AccountManager accountManager;
+ protected final Account account;
+
+ /**
+ * A cache associating Account name (email address) to a representation of the
+ * account's internal bundle.
+ * <p>
+ * The cache is invalidated entirely when <it>any</it> new Account is added,
+ * because there is no reliable way to know that an Account has been removed
+ * and then re-added.
+ */
+ protected static final ConcurrentHashMap<String, ExtendedJSONObject> perAccountBundleCache =
+ new ConcurrentHashMap<>();
+
+ public static void invalidateCaches() {
+ perAccountBundleCache.clear();
+ }
+
+ /**
+ * Create an Android Firefox Account instance backed by an Android Account
+ * instance.
+ * <p>
+ * We expect a long-lived application context to avoid life-cycle issues that
+ * might arise if the internally cached AccountManager instance surfaces UI.
+ * <p>
+ * We take care to not install any listeners or observers that might outlive
+ * the AccountManager; and Android ensures the AccountManager doesn't outlive
+ * the associated context.
+ *
+ * @param applicationContext
+ * to use as long-lived ambient Android context.
+ * @param account
+ * Android account to use for storage.
+ */
+ public AndroidFxAccount(Context applicationContext, Account account) {
+ this.context = applicationContext;
+ this.account = account;
+ this.accountManager = AccountManager.get(this.context);
+ }
+
+ public static AndroidFxAccount fromContext(Context context) {
+ context = context.getApplicationContext();
+ Account account = FirefoxAccounts.getFirefoxAccount(context);
+ if (account == null) {
+ return null;
+ }
+ return new AndroidFxAccount(context, account);
+ }
+
+ /**
+ * Persist the Firefox account to disk as a JSON object. Note that this is a wrapper around
+ * {@link AccountPickler#pickle}, and is identical to calling it directly.
+ * <p>
+ * Note that pickling is different from bundling, which involves operations on a
+ * {@link android.os.Bundle Bundle} object of miscellaneous data associated with the account.
+ * See {@link #persistBundle} and {@link #unbundle} for more.
+ */
+ public void pickle(final String filename) {
+ AccountPickler.pickle(this, filename);
+ }
+
+ public Account getAndroidAccount() {
+ return this.account;
+ }
+
+ protected int getAccountVersion() {
+ String v = accountManager.getUserData(account, ACCOUNT_KEY_ACCOUNT_VERSION);
+ if (v == null) {
+ return 0; // Implicit.
+ }
+
+ try {
+ return Integer.parseInt(v, 10);
+ } catch (NumberFormatException ex) {
+ return 0;
+ }
+ }
+
+ /**
+ * Saves the given data as the internal bundle associated with this account.
+ * @param bundle to write to account.
+ */
+ protected synchronized void persistBundle(ExtendedJSONObject bundle) {
+ perAccountBundleCache.put(account.name, bundle);
+ accountManager.setUserData(account, ACCOUNT_KEY_DESCRIPTOR, bundle.toJSONString());
+ }
+
+ protected ExtendedJSONObject unbundle() {
+ return unbundle(true);
+ }
+
+ /**
+ * Retrieve the internal bundle associated with this account.
+ * @return bundle associated with account.
+ */
+ protected synchronized ExtendedJSONObject unbundle(boolean allowCachedBundle) {
+ if (allowCachedBundle) {
+ final ExtendedJSONObject cachedBundle = perAccountBundleCache.get(account.name);
+ if (cachedBundle != null) {
+ Logger.debug(LOG_TAG, "Returning cached account bundle.");
+ return cachedBundle;
+ }
+ }
+
+ final int version = getAccountVersion();
+ if (version < CURRENT_ACCOUNT_VERSION) {
+ // Needs upgrade. For now, do nothing. We'd like to just put your account
+ // into the Separated state here and have you update your credentials.
+ return null;
+ }
+
+ if (version > CURRENT_ACCOUNT_VERSION) {
+ // Oh dear.
+ return null;
+ }
+
+ String bundleString = accountManager.getUserData(account, ACCOUNT_KEY_DESCRIPTOR);
+ if (bundleString == null) {
+ return null;
+ }
+ final ExtendedJSONObject bundle = unbundleAccountV2(bundleString);
+ perAccountBundleCache.put(account.name, bundle);
+ Logger.info(LOG_TAG, "Account bundle persisted to cache.");
+ return bundle;
+ }
+
+ protected String getBundleData(String key) {
+ ExtendedJSONObject o = unbundle();
+ if (o == null) {
+ return null;
+ }
+ return o.getString(key);
+ }
+
+ protected boolean getBundleDataBoolean(String key, boolean def) {
+ ExtendedJSONObject o = unbundle();
+ if (o == null) {
+ return def;
+ }
+ Boolean b = o.getBoolean(key);
+ if (b == null) {
+ return def;
+ }
+ return b;
+ }
+
+ protected byte[] getBundleDataBytes(String key) {
+ ExtendedJSONObject o = unbundle();
+ if (o == null) {
+ return null;
+ }
+ return o.getByteArrayHex(key);
+ }
+
+ protected void updateBundleValues(String key, String value, String... more) {
+ if (more.length % 2 != 0) {
+ throw new IllegalArgumentException("more must be a list of key, value pairs");
+ }
+ ExtendedJSONObject descriptor = unbundle();
+ if (descriptor == null) {
+ return;
+ }
+ descriptor.put(key, value);
+ for (int i = 0; i + 1 < more.length; i += 2) {
+ descriptor.put(more[i], more[i+1]);
+ }
+ persistBundle(descriptor);
+ }
+
+ private ExtendedJSONObject unbundleAccountV1(String bundle) {
+ ExtendedJSONObject o;
+ try {
+ o = new ExtendedJSONObject(bundle);
+ } catch (Exception e) {
+ return null;
+ }
+ if (CURRENT_BUNDLE_VERSION == o.getIntegerSafely(BUNDLE_KEY_BUNDLE_VERSION)) {
+ return o;
+ }
+ return null;
+ }
+
+ private ExtendedJSONObject unbundleAccountV2(String bundle) {
+ return unbundleAccountV1(bundle);
+ }
+
+ /**
+ * Note that if the user clears data, an account will be left pointing to a
+ * deleted profile. Such is life.
+ */
+ public String getProfile() {
+ return accountManager.getUserData(account, ACCOUNT_KEY_PROFILE);
+ }
+
+ public String getAccountServerURI() {
+ return accountManager.getUserData(account, ACCOUNT_KEY_IDP_SERVER);
+ }
+
+ public String getTokenServerURI() {
+ return accountManager.getUserData(account, ACCOUNT_KEY_TOKEN_SERVER);
+ }
+
+ public String getProfileServerURI() {
+ String profileURI = accountManager.getUserData(account, ACCOUNT_KEY_PROFILE_SERVER);
+ if (profileURI == null) {
+ if (isStaging()) {
+ return FxAccountConstants.STAGE_PROFILE_SERVER_ENDPOINT;
+ }
+ return FxAccountConstants.DEFAULT_PROFILE_SERVER_ENDPOINT;
+ }
+ return profileURI;
+ }
+
+ public String getOAuthServerURI() {
+ // Allow testing against stage.
+ if (isStaging()) {
+ return FxAccountConstants.STAGE_OAUTH_SERVER_ENDPOINT;
+ } else {
+ return FxAccountConstants.DEFAULT_OAUTH_SERVER_ENDPOINT;
+ }
+ }
+
+ private boolean isStaging() {
+ return FxAccountConstants.STAGE_AUTH_SERVER_ENDPOINT.equals(getAccountServerURI());
+ }
+
+ private String constructPrefsPath(String product, long version, String extra) throws GeneralSecurityException, UnsupportedEncodingException {
+ String profile = getProfile();
+ String username = account.name;
+
+ if (profile == null) {
+ throw new IllegalStateException("Missing profile. Cannot fetch prefs.");
+ }
+
+ if (username == null) {
+ throw new IllegalStateException("Missing username. Cannot fetch prefs.");
+ }
+
+ final String fxaServerURI = getAccountServerURI();
+ if (fxaServerURI == null) {
+ throw new IllegalStateException("No account server URI. Cannot fetch prefs.");
+ }
+
+ // This is unique for each syncing 'view' of the account.
+ final String serverURLThing = fxaServerURI + "!" + extra;
+ return Utils.getPrefsPath(product, username, serverURLThing, profile, version);
+ }
+
+ /**
+ * This needs to return a string because of the tortured prefs access in GlobalSession.
+ */
+ public String getSyncPrefsPath() throws GeneralSecurityException, UnsupportedEncodingException {
+ final String tokenServerURI = getTokenServerURI();
+ if (tokenServerURI == null) {
+ throw new IllegalStateException("No token server URI. Cannot fetch prefs.");
+ }
+
+ final String product = GlobalConstants.BROWSER_INTENT_PACKAGE + ".fxa";
+ final long version = CURRENT_SYNC_PREFS_VERSION;
+ return constructPrefsPath(product, version, tokenServerURI);
+ }
+
+ public String getReadingListPrefsPath() throws GeneralSecurityException, UnsupportedEncodingException {
+ final String product = GlobalConstants.BROWSER_INTENT_PACKAGE + ".reading";
+ final long version = CURRENT_RL_PREFS_VERSION;
+ return constructPrefsPath(product, version, "");
+ }
+
+ public SharedPreferences getSyncPrefs() throws UnsupportedEncodingException, GeneralSecurityException {
+ return context.getSharedPreferences(getSyncPrefsPath(), Utils.SHARED_PREFERENCES_MODE);
+ }
+
+ public SharedPreferences getReadingListPrefs() throws UnsupportedEncodingException, GeneralSecurityException {
+ return context.getSharedPreferences(getReadingListPrefsPath(), Utils.SHARED_PREFERENCES_MODE);
+ }
+
+ /**
+ * Extract a JSON dictionary of the string values associated to this account.
+ * <p>
+ * <b>For debugging use only!</b> The contents of this JSON object completely
+ * determine the user's Firefox Account status and yield access to whatever
+ * user data the device has access to.
+ *
+ * @return JSON-object of Strings.
+ */
+ public ExtendedJSONObject toJSONObject() {
+ ExtendedJSONObject o = unbundle();
+ o.put("email", account.name);
+ try {
+ o.put("emailUTF8", Utils.byte2Hex(account.name.getBytes("UTF-8")));
+ } catch (UnsupportedEncodingException e) {
+ // Ignore.
+ }
+ o.put("fxaDeviceId", getDeviceId());
+ o.put("fxaDeviceRegistrationVersion", getDeviceRegistrationVersion());
+ return o;
+ }
+
+ public static AndroidFxAccount addAndroidAccount(
+ Context context,
+ String email,
+ String profile,
+ String idpServerURI,
+ String tokenServerURI,
+ String profileServerURI,
+ State state,
+ final Map<String, Boolean> authoritiesToSyncAutomaticallyMap)
+ throws UnsupportedEncodingException, GeneralSecurityException, URISyntaxException {
+ return addAndroidAccount(context, email, profile, idpServerURI, tokenServerURI, profileServerURI, state,
+ authoritiesToSyncAutomaticallyMap,
+ CURRENT_ACCOUNT_VERSION, false, null);
+ }
+
+ public static AndroidFxAccount addAndroidAccount(
+ Context context,
+ String email,
+ String profile,
+ String idpServerURI,
+ String tokenServerURI,
+ String profileServerURI,
+ State state,
+ final Map<String, Boolean> authoritiesToSyncAutomaticallyMap,
+ final int accountVersion,
+ final boolean fromPickle,
+ ExtendedJSONObject bundle)
+ throws UnsupportedEncodingException, GeneralSecurityException, URISyntaxException {
+ if (email == null) {
+ throw new IllegalArgumentException("email must not be null");
+ }
+ if (profile == null) {
+ throw new IllegalArgumentException("profile must not be null");
+ }
+ if (idpServerURI == null) {
+ throw new IllegalArgumentException("idpServerURI must not be null");
+ }
+ if (tokenServerURI == null) {
+ throw new IllegalArgumentException("tokenServerURI must not be null");
+ }
+ if (profileServerURI == null) {
+ throw new IllegalArgumentException("profileServerURI must not be null");
+ }
+ if (state == null) {
+ throw new IllegalArgumentException("state must not be null");
+ }
+
+ // TODO: Add migration code.
+ if (accountVersion != CURRENT_ACCOUNT_VERSION) {
+ throw new IllegalStateException("Could not create account of version " + accountVersion +
+ ". Current version is " + CURRENT_ACCOUNT_VERSION + ".");
+ }
+
+ // Android has internal restrictions that require all values in this
+ // bundle to be strings. *sigh*
+ Bundle userdata = new Bundle();
+ userdata.putString(ACCOUNT_KEY_ACCOUNT_VERSION, "" + CURRENT_ACCOUNT_VERSION);
+ userdata.putString(ACCOUNT_KEY_IDP_SERVER, idpServerURI);
+ userdata.putString(ACCOUNT_KEY_TOKEN_SERVER, tokenServerURI);
+ userdata.putString(ACCOUNT_KEY_PROFILE_SERVER, profileServerURI);
+ userdata.putString(ACCOUNT_KEY_PROFILE, profile);
+
+ if (bundle == null) {
+ bundle = new ExtendedJSONObject();
+ // TODO: How to upgrade?
+ bundle.put(BUNDLE_KEY_BUNDLE_VERSION, CURRENT_BUNDLE_VERSION);
+ }
+ bundle.put(BUNDLE_KEY_STATE_LABEL, state.getStateLabel().name());
+ bundle.put(BUNDLE_KEY_STATE, state.toJSONObject().toJSONString());
+
+ userdata.putString(ACCOUNT_KEY_DESCRIPTOR, bundle.toJSONString());
+
+ Account account = new Account(email, FxAccountConstants.ACCOUNT_TYPE);
+ AccountManager accountManager = AccountManager.get(context);
+ // We don't set an Android password, because we don't want to persist the
+ // password (or anything else as powerful as the password). Instead, we
+ // internally manage a sessionToken with a remotely owned lifecycle.
+ boolean added = accountManager.addAccountExplicitly(account, null, userdata);
+ if (!added) {
+ return null;
+ }
+
+ // Try to work around an intermittent issue described at
+ // http://stackoverflow.com/a/11698139. What happens is that tests that
+ // delete and re-create the same account frequently will find the account
+ // missing all or some of the userdata bundle, possibly due to an Android
+ // AccountManager caching bug.
+ for (String key : userdata.keySet()) {
+ accountManager.setUserData(account, key, userdata.getString(key));
+ }
+
+ AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+
+ if (!fromPickle) {
+ fxAccount.clearSyncPrefs();
+ }
+
+ fxAccount.setAuthoritiesToSyncAutomaticallyMap(authoritiesToSyncAutomaticallyMap);
+
+ return fxAccount;
+ }
+
+ public void clearSyncPrefs() throws UnsupportedEncodingException, GeneralSecurityException {
+ getSyncPrefs().edit().clear().commit();
+ }
+
+ public void setAuthoritiesToSyncAutomaticallyMap(Map<String, Boolean> authoritiesToSyncAutomaticallyMap) {
+ if (authoritiesToSyncAutomaticallyMap == null) {
+ throw new IllegalArgumentException("authoritiesToSyncAutomaticallyMap must not be null");
+ }
+
+ for (String authority : DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP.keySet()) {
+ boolean authorityEnabled = DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP.get(authority);
+ final Boolean enabled = authoritiesToSyncAutomaticallyMap.get(authority);
+ if (enabled != null) {
+ authorityEnabled = enabled.booleanValue();
+ }
+ // Accounts are always capable of being synced ...
+ ContentResolver.setIsSyncable(account, authority, 1);
+ // ... but not always automatically synced.
+ ContentResolver.setSyncAutomatically(account, authority, authorityEnabled);
+ }
+ }
+
+ public Map<String, Boolean> getAuthoritiesToSyncAutomaticallyMap() {
+ final Map<String, Boolean> authoritiesToSync = new HashMap<>();
+ for (String authority : DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP.keySet()) {
+ final boolean enabled = ContentResolver.getSyncAutomatically(account, authority);
+ authoritiesToSync.put(authority, enabled);
+ }
+ return authoritiesToSync;
+ }
+
+ /**
+ * Is a sync currently in progress?
+ *
+ * @return true if Android is currently syncing the underlying Android Account.
+ */
+ public boolean isCurrentlySyncing() {
+ boolean active = false;
+ for (String authority : AndroidFxAccount.DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP.keySet()) {
+ active |= ContentResolver.isSyncActive(account, authority);
+ }
+ return active;
+ }
+
+ /**
+ * Request an immediate sync. Use this to sync as soon as possible in response to user action.
+ *
+ * @param stagesToSync stage names to sync; can be null to sync <b>all</b> known stages.
+ * @param stagesToSkip stage names to skip; can be null to skip <b>no</b> known stages.
+ */
+ public void requestImmediateSync(String[] stagesToSync, String[] stagesToSkip) {
+ FirefoxAccounts.requestImmediateSync(getAndroidAccount(), stagesToSync, stagesToSkip);
+ }
+
+ /**
+ * Request an eventual sync. Use this to request the system queue a sync for some time in the
+ * future.
+ *
+ * @param stagesToSync stage names to sync; can be null to sync <b>all</b> known stages.
+ * @param stagesToSkip stage names to skip; can be null to skip <b>no</b> known stages.
+ */
+ public void requestEventualSync(String[] stagesToSync, String[] stagesToSkip) {
+ FirefoxAccounts.requestEventualSync(getAndroidAccount(), stagesToSync, stagesToSkip);
+ }
+
+ public synchronized void setState(State state) {
+ if (state == null) {
+ throw new IllegalArgumentException("state must not be null");
+ }
+ Logger.info(LOG_TAG, "Moving account named like " + getObfuscatedEmail() +
+ " to state " + state.getStateLabel().toString());
+ updateBundleValues(
+ BUNDLE_KEY_STATE_LABEL, state.getStateLabel().name(),
+ BUNDLE_KEY_STATE, state.toJSONObject().toJSONString());
+ broadcastAccountStateChangedIntent();
+ }
+
+ protected void broadcastAccountStateChangedIntent() {
+ final Intent intent = new Intent(FxAccountConstants.ACCOUNT_STATE_CHANGED_ACTION);
+ intent.putExtra(Constants.JSON_KEY_ACCOUNT, account.name);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
+ }
+
+ public synchronized State getState() {
+ String stateLabelString = getBundleData(BUNDLE_KEY_STATE_LABEL);
+ String stateString = getBundleData(BUNDLE_KEY_STATE);
+ if (stateLabelString == null || stateString == null) {
+ throw new IllegalStateException("stateLabelString and stateString must not be null, but: " +
+ "(stateLabelString == null) = " + (stateLabelString == null) +
+ " and (stateString == null) = " + (stateString == null));
+ }
+
+ try {
+ StateLabel stateLabel = StateLabel.valueOf(stateLabelString);
+ Logger.debug(LOG_TAG, "Account is in state " + stateLabel);
+ return StateFactory.fromJSONObject(stateLabel, new ExtendedJSONObject(stateString));
+ } catch (Exception e) {
+ throw new IllegalStateException("could not get state", e);
+ }
+ }
+
+ public byte[] getSessionToken() throws InvalidFxAState {
+ State state = getState();
+ StateLabel stateLabel = state.getStateLabel();
+ if (stateLabel == StateLabel.Cohabiting || stateLabel == StateLabel.Married) {
+ TokensAndKeysState tokensAndKeysState = (TokensAndKeysState) state;
+ return tokensAndKeysState.getSessionToken();
+ }
+ throw new InvalidFxAState("Cannot get sessionToken: not in a TokensAndKeysState state");
+ }
+
+ public static class InvalidFxAState extends Exception {
+ private static final long serialVersionUID = -8537626959811195978L;
+
+ public InvalidFxAState(String message) {
+ super(message);
+ }
+ }
+
+ /**
+ * <b>For debugging only!</b>
+ */
+ public void dump() {
+ if (!FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ return;
+ }
+ ExtendedJSONObject o = toJSONObject();
+ ArrayList<String> list = new ArrayList<String>(o.keySet());
+ Collections.sort(list);
+ for (String key : list) {
+ FxAccountUtils.pii(LOG_TAG, key + ": " + o.get(key));
+ }
+ }
+
+ /**
+ * Return the Firefox Account's local email address.
+ * <p>
+ * It is important to note that this is the local email address, and not
+ * necessarily the normalized remote email address that the server expects.
+ *
+ * @return local email address.
+ */
+ public String getEmail() {
+ return account.name;
+ }
+
+ /**
+ * Return the Firefox Account's local email address, obfuscated.
+ * <p>
+ * Use this when logging.
+ *
+ * @return local email address, obfuscated.
+ */
+ public String getObfuscatedEmail() {
+ return Utils.obfuscateEmail(account.name);
+ }
+
+ /**
+ * Populate an intent used for starting FxAccountDeletedService service.
+ *
+ * @param intent Intent to populate with necessary extras
+ * @return <code>Intent</code> with a deleted action and account/OAuth information extras
+ */
+ public Intent populateDeletedAccountIntent(final Intent intent) {
+ final List<String> tokens = new ArrayList<>();
+
+ intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION_KEY,
+ Long.valueOf(FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION));
+ intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY, account.name);
+ intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_PROFILE, getProfile());
+
+ // Get the tokens from AccountManager. Note: currently, only reading list service supports OAuth. The following logic will
+ // be extended in future to support OAuth for other services.
+ for (String tokenKey : KNOWN_OAUTH_TOKEN_TYPES) {
+ final String authToken = accountManager.peekAuthToken(account, tokenKey);
+ if (authToken != null) {
+ tokens.add(authToken);
+ }
+ }
+
+ // Update intent with tokens and service URI.
+ intent.putExtra(FxAccountConstants.ACCOUNT_OAUTH_SERVICE_ENDPOINT_KEY, getOAuthServerURI());
+ // Deleted broadcasts are package-private, so there's no security risk include the tokens in the extras
+ intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_AUTH_TOKENS, tokens.toArray(new String[tokens.size()]));
+ return intent;
+ }
+
+ /**
+ * Create an intent announcing that the profile JSON attached to this Firefox Account has been updated.
+ * <p>
+ * It is not guaranteed that the profile JSON has changed.
+ *
+ * @return <code>Intent</code> to broadcast.
+ */
+ private Intent makeProfileJSONUpdatedIntent() {
+ final Intent intent = new Intent();
+ intent.setAction(FxAccountConstants.ACCOUNT_PROFILE_JSON_UPDATED_ACTION);
+ return intent;
+ }
+
+ public void setLastSyncedTimestamp(long now) {
+ try {
+ getSyncPrefs().edit().putLong(PREF_KEY_LAST_SYNCED_TIMESTAMP, now).commit();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception setting last synced time; ignoring.", e);
+ }
+ }
+
+ public long getLastSyncedTimestamp() {
+ final long neverSynced = -1L;
+ try {
+ return getSyncPrefs().getLong(PREF_KEY_LAST_SYNCED_TIMESTAMP, neverSynced);
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception getting last synced time; ignoring.", e);
+ return neverSynced;
+ }
+ }
+
+ // Debug only! This is dangerous!
+ public void unsafeTransitionToDefaultEndpoints() {
+ unsafeTransitionToStageEndpoints(
+ FxAccountConstants.DEFAULT_AUTH_SERVER_ENDPOINT,
+ FxAccountConstants.DEFAULT_TOKEN_SERVER_ENDPOINT,
+ FxAccountConstants.DEFAULT_PROFILE_SERVER_ENDPOINT);
+ }
+
+ // Debug only! This is dangerous!
+ public void unsafeTransitionToStageEndpoints() {
+ unsafeTransitionToStageEndpoints(
+ FxAccountConstants.STAGE_AUTH_SERVER_ENDPOINT,
+ FxAccountConstants.STAGE_TOKEN_SERVER_ENDPOINT,
+ FxAccountConstants.STAGE_PROFILE_SERVER_ENDPOINT);
+ }
+
+ protected void unsafeTransitionToStageEndpoints(String authServerEndpoint, String tokenServerEndpoint, String profileServerEndpoint) {
+ try {
+ getReadingListPrefs().edit().clear().commit();
+ } catch (UnsupportedEncodingException | GeneralSecurityException e) {
+ // Ignore.
+ }
+ try {
+ getSyncPrefs().edit().clear().commit();
+ } catch (UnsupportedEncodingException | GeneralSecurityException e) {
+ // Ignore.
+ }
+ State state = getState();
+ setState(state.makeSeparatedState());
+ accountManager.setUserData(account, ACCOUNT_KEY_IDP_SERVER, authServerEndpoint);
+ accountManager.setUserData(account, ACCOUNT_KEY_TOKEN_SERVER, tokenServerEndpoint);
+ accountManager.setUserData(account, ACCOUNT_KEY_PROFILE_SERVER, profileServerEndpoint);
+ ContentResolver.setIsSyncable(account, BrowserContract.READING_LIST_AUTHORITY, 1);
+ }
+
+ /**
+ * Returns the current profile JSON if available, or null.
+ *
+ * @return profile JSON object.
+ */
+ public ExtendedJSONObject getProfileJSON() {
+ final String profileString = getBundleData(BUNDLE_KEY_PROFILE_JSON);
+ if (profileString == null) {
+ return null;
+ }
+
+ try {
+ return new ExtendedJSONObject(profileString);
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Failed to parse profile JSON; ignoring and returning null.", e);
+ }
+ return null;
+ }
+
+ /**
+ * Fetch the profile JSON associated to the underlying Firefox Account from the server and update the local store.
+ * <p>
+ * The LocalBroadcastManager is used to notify the receivers asynchronously after a successful fetch.
+ */
+ public void fetchProfileJSON() {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ // Fetch profile information from server.
+ String authToken;
+ try {
+ authToken = accountManager.blockingGetAuthToken(account, AndroidFxAccount.PROFILE_OAUTH_TOKEN_TYPE, true);
+ if (authToken == null) {
+ throw new RuntimeException("Couldn't get oauth token! Aborting profile fetch.");
+ }
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Error fetching profile information; ignoring.", e);
+ return;
+ }
+
+ Logger.info(LOG_TAG, "Intent service launched to fetch profile.");
+ final Intent intent = new Intent(context, FxAccountProfileService.class);
+ intent.putExtra(FxAccountProfileService.KEY_AUTH_TOKEN, authToken);
+ intent.putExtra(FxAccountProfileService.KEY_PROFILE_SERVER_URI, getProfileServerURI());
+ intent.putExtra(FxAccountProfileService.KEY_RESULT_RECEIVER, new ProfileResultReceiver(new Handler()));
+ context.startService(intent);
+ }
+ });
+ }
+
+ @Nullable
+ public synchronized String getDeviceId() {
+ return accountManager.getUserData(account, ACCOUNT_KEY_DEVICE_ID);
+ }
+
+ @NonNull
+ public synchronized int getDeviceRegistrationVersion() {
+ String versionStr = accountManager.getUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION);
+ if (TextUtils.isEmpty(versionStr)) {
+ return 0;
+ } else {
+ try {
+ return Integer.parseInt(versionStr);
+ } catch (NumberFormatException ex) {
+ return 0;
+ }
+ }
+ }
+
+ public synchronized void setDeviceId(String id) {
+ accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_ID, id);
+ }
+
+ public synchronized void setDeviceRegistrationVersion(int deviceRegistrationVersion) {
+ accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION,
+ Integer.toString(deviceRegistrationVersion));
+ }
+
+ public synchronized void resetDeviceRegistrationVersion() {
+ setDeviceRegistrationVersion(0);
+ }
+
+ public synchronized void setFxAUserData(String id, int deviceRegistrationVersion) {
+ accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_ID, id);
+ accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION,
+ Integer.toString(deviceRegistrationVersion));
+ }
+
+ @SuppressLint("ParcelCreator") // The CREATOR field is defined in the super class.
+ private class ProfileResultReceiver extends ResultReceiver {
+ public ProfileResultReceiver(Handler handler) {
+ super(handler);
+ }
+
+ @Override
+ protected void onReceiveResult(int resultCode, Bundle bundle) {
+ super.onReceiveResult(resultCode, bundle);
+ switch (resultCode) {
+ case Activity.RESULT_OK:
+ final String resultData = bundle.getString(FxAccountProfileService.KEY_RESULT_STRING);
+ updateBundleValues(BUNDLE_KEY_PROFILE_JSON, resultData);
+ Logger.info(LOG_TAG, "Profile JSON fetch succeeeded!");
+ FxAccountUtils.pii(LOG_TAG, "Profile JSON fetch returned: " + resultData);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(makeProfileJSONUpdatedIntent());
+ break;
+ case Activity.RESULT_CANCELED:
+ Logger.warn(LOG_TAG, "Failed to fetch profile JSON; ignoring.");
+ break;
+ default:
+ Logger.warn(LOG_TAG, "Invalid result code received; ignoring.");
+ break;
+ }
+ }
+ }
+
+ /**
+ * Take the lock to own updating any Firefox Account's internal state.
+ *
+ * We use a <code>Semaphore</code> rather than a <code>ReentrantLock</code>
+ * because the callback that needs to release the lock may not be invoked on
+ * the thread that initially acquired the lock. Be aware!
+ */
+ protected static final Semaphore sLock = new Semaphore(1, true /* fair */);
+
+ // Which consumer took the lock?
+ // Synchronized by this.
+ protected String lockTag = null;
+
+ // Are we locked? (It's not easy to determine who took the lock dynamically,
+ // so we maintain this flag internally.)
+ // Synchronized by this.
+ protected boolean locked = false;
+
+ // Block until we can take the shared state lock.
+ public synchronized void acquireSharedAccountStateLock(final String tag) throws InterruptedException {
+ final long id = Thread.currentThread().getId();
+ this.lockTag = tag;
+ Log.d(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id acquiring lock: " + lockTag + ", " + id + " ...");
+ sLock.acquire();
+ locked = true;
+ Log.d(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id acquiring lock: " + lockTag + ", " + id + " ... ACQUIRED");
+ }
+
+ // If we hold the shared state lock, release it. Otherwise, ignore the request.
+ public synchronized void releaseSharedAccountStateLock() {
+ final long id = Thread.currentThread().getId();
+ Log.d(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id releasing lock: " + lockTag + ", " + id + " ...");
+ if (locked) {
+ sLock.release();
+ locked = false;
+ Log.d(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id releasing lock: " + lockTag + ", " + id + " ... RELEASED");
+ } else {
+ Log.d(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id releasing lock: " + lockTag + ", " + id + " ... NOT LOCKED");
+ }
+ }
+
+ @Override
+ protected synchronized void finalize() {
+ if (locked) {
+ // Should never happen, but...
+ sLock.release();
+ locked = false;
+ final long id = Thread.currentThread().getId();
+ Log.e(Logger.DEFAULT_LOG_TAG, "Thread with tag and thread id releasing lock: " + lockTag + ", " + id + " ... RELEASED DURING FINALIZE");
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxADefaultLoginStateMachineDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxADefaultLoginStateMachineDelegate.java
new file mode 100644
index 000000000..ff3122322
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxADefaultLoginStateMachineDelegate.java
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.authenticator;
+
+import java.security.NoSuchAlgorithmException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition;
+import org.mozilla.gecko.fxa.login.Married;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.fxa.login.StateFactory;
+import org.mozilla.gecko.fxa.sync.FxAccountNotificationManager;
+import org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter;
+
+import android.content.Context;
+
+public abstract class FxADefaultLoginStateMachineDelegate implements LoginStateMachineDelegate {
+ protected final static String LOG_TAG = LoginStateMachineDelegate.class.getSimpleName();
+
+ protected final Context context;
+ protected final AndroidFxAccount fxAccount;
+ protected final Executor executor;
+ protected final FxAccountClient client;
+
+ public FxADefaultLoginStateMachineDelegate(Context context, AndroidFxAccount fxAccount) {
+ this.context = context;
+ this.fxAccount = fxAccount;
+ this.executor = Executors.newSingleThreadExecutor();
+ this.client = new FxAccountClient20(fxAccount.getAccountServerURI(), executor);
+ }
+
+ abstract public void handleNotMarried(State notMarried);
+ abstract public void handleMarried(Married married);
+
+ @Override
+ public FxAccountClient getClient() {
+ return client;
+ }
+
+ @Override
+ public long getCertificateDurationInMilliseconds() {
+ return 12 * 60 * 60 * 1000;
+ }
+
+ @Override
+ public long getAssertionDurationInMilliseconds() {
+ return 15 * 60 * 1000;
+ }
+
+ @Override
+ public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException {
+ return StateFactory.generateKeyPair();
+ }
+
+ @Override
+ public void handleTransition(Transition transition, State state) {
+ Logger.info(LOG_TAG, "handleTransition: " + transition + " to " + state.getStateLabel());
+ }
+
+ @Override
+ public void handleFinal(State state) {
+ Logger.info(LOG_TAG, "handleFinal: in " + state.getStateLabel());
+ fxAccount.setState(state);
+ // Update any notifications displayed.
+ final FxAccountNotificationManager notificationManager = new FxAccountNotificationManager(FxAccountSyncAdapter.NOTIFICATION_ID);
+ notificationManager.update(context, fxAccount);
+
+ if (state.getStateLabel() != StateLabel.Married) {
+ handleNotMarried(state);
+ return;
+ } else {
+ handleMarried((Married) state);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticator.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticator.java
new file mode 100644
index 000000000..259b1cb88
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticator.java
@@ -0,0 +1,385 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.authenticator;
+
+import android.accounts.AbstractAccountAuthenticator;
+import android.accounts.Account;
+import android.accounts.AccountAuthenticatorResponse;
+import android.accounts.AccountManager;
+import android.accounts.NetworkErrorException;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient.RequestDelegate;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10.AuthorizationResponse;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.JSONWebTokenUtils;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition;
+import org.mozilla.gecko.fxa.login.Married;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.fxa.login.StateFactory;
+import org.mozilla.gecko.fxa.receivers.FxAccountDeletedService;
+import org.mozilla.gecko.fxa.sync.FxAccountNotificationManager;
+import org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.security.NoSuchAlgorithmException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+public class FxAccountAuthenticator extends AbstractAccountAuthenticator {
+ public static final String LOG_TAG = FxAccountAuthenticator.class.getSimpleName();
+ public static final int UNKNOWN_ERROR_CODE = 999;
+
+ protected final Context context;
+ protected final AccountManager accountManager;
+
+ public FxAccountAuthenticator(Context context) {
+ super(context);
+ this.context = context;
+ this.accountManager = AccountManager.get(context);
+ }
+
+ @Override
+ public Bundle addAccount(AccountAuthenticatorResponse response,
+ String accountType, String authTokenType, String[] requiredFeatures,
+ Bundle options)
+ throws NetworkErrorException {
+ Logger.debug(LOG_TAG, "addAccount");
+
+ // The data associated to each Account should be invalidated when we change
+ // the set of Firefox Accounts on the system.
+ AndroidFxAccount.invalidateCaches();
+
+ final Bundle res = new Bundle();
+
+ if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) {
+ res.putInt(AccountManager.KEY_ERROR_CODE, -1);
+ res.putString(AccountManager.KEY_ERROR_MESSAGE, "Not adding unknown account type.");
+ return res;
+ }
+
+ final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED);
+ res.putParcelable(AccountManager.KEY_INTENT, intent);
+ return res;
+ }
+
+ @Override
+ public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options)
+ throws NetworkErrorException {
+ Logger.debug(LOG_TAG, "confirmCredentials");
+
+ return null;
+ }
+
+ @Override
+ public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
+ Logger.debug(LOG_TAG, "editProperties");
+
+ return null;
+ }
+
+ protected static class Responder {
+ final AccountAuthenticatorResponse response;
+ final AndroidFxAccount fxAccount;
+
+ public Responder(AccountAuthenticatorResponse response, AndroidFxAccount fxAccount) {
+ this.response = response;
+ this.fxAccount = fxAccount;
+ }
+
+ public void fail(Exception e) {
+ Logger.warn(LOG_TAG, "Responding with error!", e);
+ fxAccount.releaseSharedAccountStateLock();
+ final Bundle result = new Bundle();
+ result.putInt(AccountManager.KEY_ERROR_CODE, UNKNOWN_ERROR_CODE);
+ result.putString(AccountManager.KEY_ERROR_MESSAGE, e.toString());
+ response.onResult(result);
+ }
+
+ public void succeed(String authToken) {
+ Logger.info(LOG_TAG, "Responding with success!");
+ fxAccount.releaseSharedAccountStateLock();
+ final Bundle result = new Bundle();
+ result.putString(AccountManager.KEY_ACCOUNT_NAME, fxAccount.account.name);
+ result.putString(AccountManager.KEY_ACCOUNT_TYPE, fxAccount.account.type);
+ result.putString(AccountManager.KEY_AUTHTOKEN, authToken);
+ response.onResult(result);
+ }
+ }
+
+ public abstract static class FxADefaultLoginStateMachineDelegate implements LoginStateMachineDelegate {
+ protected final Context context;
+ protected final AndroidFxAccount fxAccount;
+ protected final Executor executor;
+ protected final FxAccountClient client;
+
+ public FxADefaultLoginStateMachineDelegate(Context context, AndroidFxAccount fxAccount) {
+ this.context = context;
+ this.fxAccount = fxAccount;
+ this.executor = Executors.newSingleThreadExecutor();
+ this.client = new FxAccountClient20(fxAccount.getAccountServerURI(), executor);
+ }
+
+ @Override
+ public FxAccountClient getClient() {
+ return client;
+ }
+
+ @Override
+ public long getCertificateDurationInMilliseconds() {
+ return 12 * 60 * 60 * 1000;
+ }
+
+ @Override
+ public long getAssertionDurationInMilliseconds() {
+ return 15 * 60 * 1000;
+ }
+
+ @Override
+ public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException {
+ return StateFactory.generateKeyPair();
+ }
+
+ @Override
+ public void handleTransition(Transition transition, State state) {
+ Logger.info(LOG_TAG, "handleTransition: " + transition + " to " + state.getStateLabel());
+ }
+
+ abstract public void handleNotMarried(State notMarried);
+ abstract public void handleMarried(Married married);
+
+ @Override
+ public void handleFinal(State state) {
+ Logger.info(LOG_TAG, "handleFinal: in " + state.getStateLabel());
+ fxAccount.setState(state);
+ // Update any notifications displayed.
+ final FxAccountNotificationManager notificationManager = new FxAccountNotificationManager(FxAccountSyncAdapter.NOTIFICATION_ID);
+ notificationManager.update(context, fxAccount);
+
+ if (state.getStateLabel() != StateLabel.Married) {
+ handleNotMarried(state);
+ return;
+ } else {
+ handleMarried((Married) state);
+ }
+ }
+ }
+
+ protected void getOAuthToken(final AccountAuthenticatorResponse response, final AndroidFxAccount fxAccount, final String scope) throws NetworkErrorException {
+ Logger.info(LOG_TAG, "Fetching oauth token with scope: " + scope);
+
+ final Responder responder = new Responder(response, fxAccount);
+ final String oauthServerUri = fxAccount.getOAuthServerURI();
+
+ final String audience;
+ try {
+ audience = FxAccountUtils.getAudienceForURL(oauthServerUri); // The assertion gets traded in for an oauth bearer token.
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception fetching oauth token.", e);
+ responder.fail(e);
+ return;
+ }
+
+ final FxAccountLoginStateMachine stateMachine = new FxAccountLoginStateMachine();
+
+ stateMachine.advance(fxAccount.getState(), StateLabel.Married, new FxADefaultLoginStateMachineDelegate(context, fxAccount) {
+ @Override
+ public void handleNotMarried(State state) {
+ final String message = "Cannot fetch oauth token from state: " + state.getStateLabel();
+ Logger.warn(LOG_TAG, message);
+ responder.fail(new RuntimeException(message));
+ }
+
+ @Override
+ public void handleMarried(final Married married) {
+ final String assertion;
+ try {
+ assertion = married.generateAssertion(audience, JSONWebTokenUtils.DEFAULT_ASSERTION_ISSUER);
+ if (FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ JSONWebTokenUtils.dumpAssertion(assertion);
+ }
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception fetching oauth token.", e);
+ responder.fail(e);
+ return;
+ }
+
+ final FxAccountOAuthClient10 oauthClient = new FxAccountOAuthClient10(oauthServerUri, executor);
+ Logger.debug(LOG_TAG, "OAuth fetch for scope: " + scope);
+ oauthClient.authorization(FxAccountConstants.OAUTH_CLIENT_ID_FENNEC, assertion, null, scope, new RequestDelegate<FxAccountOAuthClient10.AuthorizationResponse>() {
+ @Override
+ public void handleSuccess(AuthorizationResponse result) {
+ Logger.debug(LOG_TAG, "OAuth success.");
+ FxAccountUtils.pii(LOG_TAG, "Fetched oauth token: " + result.access_token);
+ responder.succeed(result.access_token);
+ }
+
+ @Override
+ public void handleFailure(FxAccountAbstractClientRemoteException e) {
+ Logger.error(LOG_TAG, "OAuth failure.", e);
+ if (e.isInvalidAuthentication()) {
+ // We were married, generated an assertion, and our assertion was rejected by the
+ // oauth client. If it's a 401, we probably have a stale certificate. If instead of
+ // a stale certificate we have bad credentials, the state machine will fail to sign
+ // our public key and drive us back to Separated.
+ fxAccount.setState(married.makeCohabitingState());
+ }
+ responder.fail(e);
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ Logger.error(LOG_TAG, "OAuth error.", e);
+ responder.fail(e);
+ }
+ });
+ }
+ });
+ }
+
+ @Override
+ public Bundle getAuthToken(final AccountAuthenticatorResponse response,
+ final Account account, final String authTokenType, final Bundle options)
+ throws NetworkErrorException {
+ Logger.debug(LOG_TAG, "getAuthToken: " + authTokenType);
+
+ // If we have a cached authToken, hand it over.
+ final String cachedAuthToken = AccountManager.get(context).peekAuthToken(account, authTokenType);
+ if (cachedAuthToken != null && !cachedAuthToken.isEmpty()) {
+ Logger.info(LOG_TAG, "Return cached token.");
+ final Bundle result = new Bundle();
+ result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
+ result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type);
+ result.putString(AccountManager.KEY_AUTHTOKEN, cachedAuthToken);
+ return result;
+ }
+
+ // If we're asked for an oauth::scope token, try to generate one.
+ final String oauthPrefix = "oauth::";
+ if (authTokenType != null && authTokenType.startsWith(oauthPrefix)) {
+ final String scope = authTokenType.substring(oauthPrefix.length());
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+ try {
+ fxAccount.acquireSharedAccountStateLock(LOG_TAG);
+ } catch (InterruptedException e) {
+ Logger.warn(LOG_TAG, "Could not acquire account state lock; return error bundle.");
+ final Bundle bundle = new Bundle();
+ bundle.putInt(AccountManager.KEY_ERROR_CODE, 1);
+ bundle.putString(AccountManager.KEY_ERROR_MESSAGE, "Could not acquire account state lock.");
+ return bundle;
+ }
+ getOAuthToken(response, fxAccount, scope);
+ return null;
+ }
+
+ // Otherwise, fail.
+ Logger.warn(LOG_TAG, "Returning error bundle for getAuthToken with unknown token type.");
+ final Bundle bundle = new Bundle();
+ bundle.putInt(AccountManager.KEY_ERROR_CODE, 2);
+ bundle.putString(AccountManager.KEY_ERROR_MESSAGE, "Unknown token type: " + authTokenType);
+ return bundle;
+ }
+
+ @Override
+ public String getAuthTokenLabel(String authTokenType) {
+ Logger.debug(LOG_TAG, "getAuthTokenLabel");
+
+ return null;
+ }
+
+ @Override
+ public Bundle hasFeatures(AccountAuthenticatorResponse response,
+ Account account, String[] features) throws NetworkErrorException {
+ Logger.debug(LOG_TAG, "hasFeatures");
+
+ return null;
+ }
+
+ @Override
+ public Bundle updateCredentials(AccountAuthenticatorResponse response,
+ Account account, String authTokenType, Bundle options)
+ throws NetworkErrorException {
+ Logger.debug(LOG_TAG, "updateCredentials");
+
+ return null;
+ }
+
+ /**
+ * If the account is going to be removed, broadcast an "account deleted"
+ * intent. This allows us to clean up the account.
+ * <p>
+ * It is preferable to receive Android's LOGIN_ACCOUNTS_CHANGED_ACTION broadcast
+ * than to create our own hacky broadcast here, but that doesn't include enough
+ * information about which Accounts changed to correctly identify whether a Sync
+ * account has been removed (when some Firefox channels are installed on the SD
+ * card). We can work around this by storing additional state but it's both messy
+ * and expensive because the broadcast is noisy.
+ * <p>
+ * Note that this is <b>not</b> called when an Android Account is blown away
+ * due to the SD card being unmounted.
+ */
+ @Override
+ public Bundle getAccountRemovalAllowed(final AccountAuthenticatorResponse response, Account account)
+ throws NetworkErrorException {
+ Bundle result = super.getAccountRemovalAllowed(response, account);
+
+ if (result == null ||
+ !result.containsKey(AccountManager.KEY_BOOLEAN_RESULT) ||
+ result.containsKey(AccountManager.KEY_INTENT)) {
+ return result;
+ }
+
+ final boolean removalAllowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT);
+ if (!removalAllowed) {
+ return result;
+ }
+
+ // Broadcast a message to all Firefox channels sharing this Android
+ // Account type telling that this Firefox account has been deleted.
+ //
+ // Broadcast intents protected with permissions are secure, so it's okay
+ // to include private information such as a password.
+ final AndroidFxAccount androidFxAccount = new AndroidFxAccount(context, account);
+
+ // Deleting the pickle file in a blocking manner will avoid race conditions that might happen when
+ // an account is unpickled while an FxAccount is being deleted.
+ // Also we have an assumption that this method is always called from a background thread, so we delete
+ // the pickle file directly without being afraid from a StrictMode violation.
+ ThreadUtils.assertNotOnUiThread();
+
+ final Intent serviceIntent = androidFxAccount.populateDeletedAccountIntent(
+ new Intent(context, FxAccountDeletedService.class)
+ );
+ Logger.info(LOG_TAG, "Account named " + account.name + " being removed; " +
+ "starting FxAccountDeletedService with action: " + serviceIntent.getAction() + ".");
+ context.startService(serviceIntent);
+
+ Logger.info(LOG_TAG, "Firefox account named " + account.name + " being removed; " +
+ "deleting saved pickle file '" + FxAccountConstants.ACCOUNT_PICKLE_FILENAME + "'.");
+ deletePickle();
+
+ return result;
+ }
+
+ private void deletePickle() {
+ try {
+ AccountPickler.deletePickle(context, FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
+ } catch (Exception e) {
+ // This should never happen, but we really don't want to die in a background thread.
+ Logger.warn(LOG_TAG, "Got exception deleting saved pickle file; ignoring.", e);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticatorService.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticatorService.java
new file mode 100644
index 000000000..d138e6c45
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountAuthenticatorService.java
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.authenticator;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.IBinder;
+
+public class FxAccountAuthenticatorService extends Service {
+ public static final String LOG_TAG = FxAccountAuthenticatorService.class.getSimpleName();
+
+ // Lazily initialized by <code>getAuthenticator</code>.
+ protected FxAccountAuthenticator accountAuthenticator;
+
+ protected synchronized FxAccountAuthenticator getAuthenticator() {
+ if (accountAuthenticator == null) {
+ accountAuthenticator = new FxAccountAuthenticator(this);
+ }
+
+ return accountAuthenticator;
+ }
+
+ @Override
+ public void onCreate() {
+ Logger.debug(LOG_TAG, "onCreate");
+
+ accountAuthenticator = getAuthenticator();
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ Logger.debug(LOG_TAG, "onBind");
+
+ if (intent == null) {
+ // Should never happen, but can -- Bug 1025937.
+ return null;
+ }
+
+ if (!android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT.equals(intent.getAction())) {
+ return null;
+ }
+
+ final FxAccountAuthenticator authenticator = getAuthenticator();
+ if (authenticator == null) {
+ // Should never happen.
+ return null;
+ }
+
+ return authenticator.getIBinder();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginDelegate.java
new file mode 100644
index 000000000..71006e79d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginDelegate.java
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.authenticator;
+
+/**
+ * Abstraction around things that might need to be signalled to the user via UI,
+ * such as:
+ * <ul>
+ * <li>account not yet verified;</li>
+ * <li>account password needs to be updated;</li>
+ * <li>account key management required or changed;</li>
+ * <li>auth protocol has changed and Firefox needs to be upgraded;</li>
+ * </ul>
+ * etc.
+ * <p>
+ * Consumers of this code should differentiate error classes based on the types
+ * of the exceptions thrown. Exceptions that do not have special meaning are of
+ * type <code>FxAccountLoginException</code> with an appropriate
+ * <code>cause</code> inner exception.
+ */
+public interface FxAccountLoginDelegate {
+ public void handleError(FxAccountLoginException e);
+ public void handleSuccess(String assertion);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginException.java
new file mode 100644
index 000000000..56c0140b2
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/FxAccountLoginException.java
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.authenticator;
+
+public class FxAccountLoginException extends Exception {
+ public FxAccountLoginException(String string) {
+ super(string);
+ }
+
+ public FxAccountLoginException(Exception e) {
+ super(e);
+ }
+
+ private static final long serialVersionUID = 397685959625820798L;
+
+ public static class FxAccountLoginBadPasswordException extends FxAccountLoginException {
+ public FxAccountLoginBadPasswordException(String string) {
+ super(string);
+ }
+
+ private static final long serialVersionUID = 397685959625820799L;
+ }
+
+ public static class FxAccountLoginAccountNotVerifiedException extends FxAccountLoginException {
+ public FxAccountLoginAccountNotVerifiedException(String string) {
+ super(string);
+ }
+
+ private static final long serialVersionUID = 397685959625820800L;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/BaseRequestDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/BaseRequestDelegate.java
new file mode 100644
index 000000000..5d3e71ece
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/BaseRequestDelegate.java
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.AccountNeedsVerification;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LocalError;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.RemoteError;
+
+public abstract class BaseRequestDelegate<T> implements FxAccountClient20.RequestDelegate<T> {
+ protected final ExecuteDelegate delegate;
+ protected final State state;
+
+ public BaseRequestDelegate(State state, ExecuteDelegate delegate) {
+ this.delegate = delegate;
+ this.state = state;
+ }
+
+ @Override
+ public void handleFailure(FxAccountClientRemoteException e) {
+ // Order matters here: we don't want to ignore upgrade required responses
+ // even if the server tells us something else as well. We don't go directly
+ // to the Doghouse on upgrade required; we want the user to try to update
+ // their credentials, and then display UI telling them they need to upgrade.
+ // Then they go to the Doghouse.
+ if (e.isUpgradeRequired()) {
+ delegate.handleTransition(new RemoteError(e), new Separated(state.email, state.uid, state.verified));
+ return;
+ }
+ if (e.isInvalidAuthentication()) {
+ delegate.handleTransition(new RemoteError(e), new Separated(state.email, state.uid, state.verified));
+ return;
+ }
+ if (e.isUnverified()) {
+ delegate.handleTransition(new AccountNeedsVerification(), state);
+ return;
+ }
+ delegate.handleTransition(new RemoteError(e), state);
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ delegate.handleTransition(new LocalError(e), state);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Cohabiting.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Cohabiting.java
new file mode 100644
index 000000000..dd3477a79
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Cohabiting.java
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.JSONWebTokenUtils;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LogMessage;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+public class Cohabiting extends TokensAndKeysState {
+ private static final String LOG_TAG = Cohabiting.class.getSimpleName();
+
+ public Cohabiting(String email, String uid, byte[] sessionToken, byte[] kA, byte[] kB, BrowserIDKeyPair keyPair) {
+ super(StateLabel.Cohabiting, email, uid, sessionToken, kA, kB, keyPair);
+ }
+
+ public Married withCertificate(String certificate) {
+ return new Married(email, uid, sessionToken, kA, kB, keyPair, certificate);
+ }
+
+ @Override
+ public void execute(final ExecuteDelegate delegate) {
+ delegate.getClient().sign(sessionToken, keyPair.getPublic().toJSONObject(), delegate.getCertificateDurationInMilliseconds(),
+ new BaseRequestDelegate<String>(this, delegate) {
+ @Override
+ public void handleSuccess(String certificate) {
+ if (FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ try {
+ FxAccountUtils.pii(LOG_TAG, "Fetched certificate: " + certificate);
+ ExtendedJSONObject c = JSONWebTokenUtils.parseCertificate(certificate);
+ if (c != null) {
+ FxAccountUtils.pii(LOG_TAG, "Header : " + c.getObject("header"));
+ FxAccountUtils.pii(LOG_TAG, "Payload : " + c.getObject("payload"));
+ FxAccountUtils.pii(LOG_TAG, "Signature: " + c.getString("signature"));
+ } else {
+ FxAccountUtils.pii(LOG_TAG, "Could not parse certificate!");
+ }
+ } catch (Exception e) {
+ FxAccountUtils.pii(LOG_TAG, "Could not parse certificate!");
+ }
+ }
+ delegate.handleTransition(new LogMessage("sign succeeded"), withCertificate(certificate));
+ }
+ });
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Doghouse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Doghouse.java
new file mode 100644
index 000000000..57600577d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Doghouse.java
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LogMessage;
+
+
+public class Doghouse extends State {
+ public Doghouse(String email, String uid, boolean verified) {
+ super(StateLabel.Doghouse, email, uid, verified);
+ }
+
+ @Override
+ public void execute(final ExecuteDelegate delegate) {
+ delegate.handleTransition(new LogMessage("Upgraded Firefox clients might know what to do here."), this);
+ }
+
+ @Override
+ public Action getNeededAction() {
+ return Action.NeedsUpgrade;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Engaged.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Engaged.java
new file mode 100644
index 000000000..f192cb58b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Engaged.java
@@ -0,0 +1,91 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+import java.security.NoSuchAlgorithmException;
+
+import org.mozilla.gecko.background.fxa.FxAccountClient20.TwoKeys;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.AccountVerified;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LocalError;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LogMessage;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.RemoteError;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+
+public class Engaged extends State {
+ private static final String LOG_TAG = Engaged.class.getSimpleName();
+
+ protected final byte[] sessionToken;
+ protected final byte[] keyFetchToken;
+ protected final byte[] unwrapkB;
+
+ public Engaged(String email, String uid, boolean verified, byte[] unwrapkB, byte[] sessionToken, byte[] keyFetchToken) {
+ super(StateLabel.Engaged, email, uid, verified);
+ Utils.throwIfNull(unwrapkB, sessionToken, keyFetchToken);
+ this.unwrapkB = unwrapkB;
+ this.sessionToken = sessionToken;
+ this.keyFetchToken = keyFetchToken;
+ }
+
+ @Override
+ public ExtendedJSONObject toJSONObject() {
+ ExtendedJSONObject o = super.toJSONObject();
+ // Fields are non-null by constructor.
+ o.put("unwrapkB", Utils.byte2Hex(unwrapkB));
+ o.put("sessionToken", Utils.byte2Hex(sessionToken));
+ o.put("keyFetchToken", Utils.byte2Hex(keyFetchToken));
+ return o;
+ }
+
+ @Override
+ public void execute(final ExecuteDelegate delegate) {
+ BrowserIDKeyPair theKeyPair;
+ try {
+ theKeyPair = delegate.generateKeyPair();
+ } catch (NoSuchAlgorithmException e) {
+ delegate.handleTransition(new LocalError(e), new Doghouse(email, uid, verified));
+ return;
+ }
+ final BrowserIDKeyPair keyPair = theKeyPair;
+
+ delegate.getClient().keys(keyFetchToken, new BaseRequestDelegate<TwoKeys>(this, delegate) {
+ @Override
+ public void handleSuccess(TwoKeys result) {
+ byte[] kB;
+ try {
+ kB = FxAccountUtils.unwrapkB(unwrapkB, result.wrapkB);
+ if (FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ FxAccountUtils.pii(LOG_TAG, "Fetched kA: " + Utils.byte2Hex(result.kA));
+ FxAccountUtils.pii(LOG_TAG, "And wrapkB: " + Utils.byte2Hex(result.wrapkB));
+ FxAccountUtils.pii(LOG_TAG, "Giving kB : " + Utils.byte2Hex(kB));
+ }
+ } catch (Exception e) {
+ delegate.handleTransition(new RemoteError(e), new Separated(email, uid, verified));
+ return;
+ }
+ Transition transition = verified
+ ? new LogMessage("keys succeeded")
+ : new AccountVerified();
+ delegate.handleTransition(transition, new Cohabiting(email, uid, sessionToken, result.kA, kB, keyPair));
+ }
+ });
+ }
+
+ @Override
+ public Action getNeededAction() {
+ if (!verified) {
+ return Action.NeedsVerification;
+ }
+ return Action.None;
+ }
+
+ public byte[] getSessionToken() {
+ return sessionToken;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginStateMachine.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginStateMachine.java
new file mode 100644
index 000000000..34e507541
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginStateMachine.java
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+import java.security.NoSuchAlgorithmException;
+import java.util.EnumSet;
+import java.util.Set;
+
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+
+public class FxAccountLoginStateMachine {
+ public static final String LOG_TAG = FxAccountLoginStateMachine.class.getSimpleName();
+
+ public interface LoginStateMachineDelegate {
+ public FxAccountClient getClient();
+ public long getCertificateDurationInMilliseconds();
+ public long getAssertionDurationInMilliseconds();
+ public void handleTransition(Transition transition, State state);
+ public void handleFinal(State state);
+ public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException;
+ }
+
+ public static class ExecuteDelegate {
+ protected final LoginStateMachineDelegate delegate;
+ protected final StateLabel desiredStateLabel;
+ // It's as difficult to detect arbitrary cycles as repeated states.
+ protected final Set<StateLabel> stateLabelsSeen = EnumSet.noneOf(StateLabel.class);
+
+ protected ExecuteDelegate(StateLabel initialStateLabel, StateLabel desiredStateLabel, LoginStateMachineDelegate delegate) {
+ this.delegate = delegate;
+ this.desiredStateLabel = desiredStateLabel;
+ this.stateLabelsSeen.add(initialStateLabel);
+ }
+
+ public FxAccountClient getClient() {
+ return delegate.getClient();
+ }
+
+ public long getCertificateDurationInMilliseconds() {
+ return delegate.getCertificateDurationInMilliseconds();
+ }
+
+ public long getAssertionDurationInMilliseconds() {
+ return delegate.getAssertionDurationInMilliseconds();
+ }
+
+ public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException {
+ return delegate.generateKeyPair();
+ }
+
+ public void handleTransition(Transition transition, State state) {
+ // Always trigger the transition callback.
+ delegate.handleTransition(transition, state);
+
+ // Possibly trigger the final callback. We trigger if we're at our desired
+ // state, or if we've seen this state before.
+ StateLabel stateLabel = state.getStateLabel();
+ if (stateLabel == desiredStateLabel || stateLabelsSeen.contains(stateLabel)) {
+ delegate.handleFinal(state);
+ return;
+ }
+
+ // If this wasn't the last state, leave a bread crumb and move on to the
+ // next state.
+ stateLabelsSeen.add(stateLabel);
+ state.execute(this);
+ }
+ }
+
+ public void advance(State initialState, final StateLabel desiredStateLabel, final LoginStateMachineDelegate delegate) {
+ if (initialState.getStateLabel() == desiredStateLabel) {
+ // We're already where we want to be!
+ delegate.handleFinal(initialState);
+ return;
+ }
+ ExecuteDelegate executeDelegate = new ExecuteDelegate(initialState.getStateLabel(), desiredStateLabel, delegate);
+ initialState.execute(executeDelegate);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginTransition.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginTransition.java
new file mode 100644
index 000000000..683217853
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/FxAccountLoginTransition.java
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+
+public class FxAccountLoginTransition {
+ public interface Transition {
+ }
+
+ public static class LogMessage implements Transition {
+ public final String detailMessage;
+
+ public LogMessage(String detailMessage) {
+ this.detailMessage = detailMessage;
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + (this.detailMessage == null ? "" : "('" + this.detailMessage + "')");
+ }
+ }
+
+ public static class AccountNeedsVerification extends LogMessage {
+ public AccountNeedsVerification() {
+ super(null);
+ }
+ }
+
+ public static class AccountVerified extends LogMessage {
+ public AccountVerified() {
+ super(null);
+ }
+ }
+
+ public static class PasswordRequired extends LogMessage {
+ public PasswordRequired() {
+ super(null);
+ }
+ }
+
+ public static class LocalError implements Transition {
+ public final Exception e;
+
+ public LocalError(Exception e) {
+ this.e = e;
+ }
+
+ @Override
+ public String toString() {
+ return "Log(" + this.e + ")";
+ }
+ }
+
+ public static class RemoteError implements Transition {
+ public final Exception e;
+
+ public RemoteError(Exception e) {
+ this.e = e;
+ }
+
+ @Override
+ public String toString() {
+ return "Log(" + (this.e == null ? "null" : this.e) + ")";
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Married.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Married.java
new file mode 100644
index 000000000..1ec7b4051
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Married.java
@@ -0,0 +1,117 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.JSONWebTokenUtils;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LogMessage;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+
+public class Married extends TokensAndKeysState {
+ private static final String LOG_TAG = Married.class.getSimpleName();
+
+ protected final String certificate;
+ protected final String clientState;
+
+ public Married(String email, String uid, byte[] sessionToken, byte[] kA, byte[] kB, BrowserIDKeyPair keyPair, String certificate) {
+ super(StateLabel.Married, email, uid, sessionToken, kA, kB, keyPair);
+ Utils.throwIfNull(certificate);
+ this.certificate = certificate;
+ try {
+ this.clientState = FxAccountUtils.computeClientState(kB);
+ } catch (NoSuchAlgorithmException e) {
+ // This should never occur.
+ throw new IllegalStateException("Unable to compute client state from kB.");
+ }
+ }
+
+ @Override
+ public ExtendedJSONObject toJSONObject() {
+ ExtendedJSONObject o = super.toJSONObject();
+ // Fields are non-null by constructor.
+ o.put("certificate", certificate);
+ return o;
+ }
+
+ @Override
+ public void execute(final ExecuteDelegate delegate) {
+ delegate.handleTransition(new LogMessage("staying married"), this);
+ }
+
+ public String generateAssertion(String audience, String issuer) throws NonObjectJSONException, IOException, GeneralSecurityException {
+ // We generate assertions with no iat and an exp after 2050 to avoid
+ // invalid-timestamp errors from the token server.
+ final long expiresAt = JSONWebTokenUtils.DEFAULT_FUTURE_EXPIRES_AT_IN_MILLISECONDS;
+ String assertion = JSONWebTokenUtils.createAssertion(keyPair.getPrivate(), certificate, audience, issuer, null, expiresAt);
+ if (!FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ return assertion;
+ }
+
+ try {
+ FxAccountUtils.pii(LOG_TAG, "Generated assertion: " + assertion);
+ ExtendedJSONObject a = JSONWebTokenUtils.parseAssertion(assertion);
+ if (a != null) {
+ FxAccountUtils.pii(LOG_TAG, "aHeader : " + a.getObject("header"));
+ FxAccountUtils.pii(LOG_TAG, "aPayload : " + a.getObject("payload"));
+ FxAccountUtils.pii(LOG_TAG, "aSignature: " + a.getString("signature"));
+ String certificate = a.getString("certificate");
+ if (certificate != null) {
+ ExtendedJSONObject c = JSONWebTokenUtils.parseCertificate(certificate);
+ FxAccountUtils.pii(LOG_TAG, "cHeader : " + c.getObject("header"));
+ FxAccountUtils.pii(LOG_TAG, "cPayload : " + c.getObject("payload"));
+ FxAccountUtils.pii(LOG_TAG, "cSignature: " + c.getString("signature"));
+ // Print the relevant timestamps in sorted order with labels.
+ HashMap<Long, String> map = new HashMap<Long, String>();
+ map.put(a.getObject("payload").getLong("iat"), "aiat");
+ map.put(a.getObject("payload").getLong("exp"), "aexp");
+ map.put(c.getObject("payload").getLong("iat"), "ciat");
+ map.put(c.getObject("payload").getLong("exp"), "cexp");
+ ArrayList<Long> values = new ArrayList<Long>(map.keySet());
+ Collections.sort(values);
+ for (Long value : values) {
+ FxAccountUtils.pii(LOG_TAG, map.get(value) + ": " + value);
+ }
+ } else {
+ FxAccountUtils.pii(LOG_TAG, "Could not parse certificate!");
+ }
+ } else {
+ FxAccountUtils.pii(LOG_TAG, "Could not parse assertion!");
+ }
+ } catch (Exception e) {
+ FxAccountUtils.pii(LOG_TAG, "Got exception dumping assertion debug info.");
+ }
+ return assertion;
+ }
+
+ public KeyBundle getSyncKeyBundle() throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
+ // TODO Document this choice for deriving from kB.
+ return FxAccountUtils.generateSyncKeyBundle(kB);
+ }
+
+ public String getClientState() {
+ if (FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ FxAccountUtils.pii(LOG_TAG, "Client state: " + this.clientState);
+ }
+ return this.clientState;
+ }
+
+ public Cohabiting makeCohabitingState() {
+ return new Cohabiting(email, uid, sessionToken, kA, kB, keyPair);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/MigratedFromSync11.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/MigratedFromSync11.java
new file mode 100644
index 000000000..c30ac2ff7
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/MigratedFromSync11.java
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.PasswordRequired;
+
+public class MigratedFromSync11 extends State {
+ public final String password;
+
+ public MigratedFromSync11(String email, String uid, boolean verified, String password) {
+ super(StateLabel.MigratedFromSync11, email, uid, verified);
+ // Null password is allowed.
+ this.password = password;
+ }
+
+ @Override
+ public void execute(final ExecuteDelegate delegate) {
+ delegate.handleTransition(new PasswordRequired(), this);
+ }
+
+ @Override
+ public Action getNeededAction() {
+ return Action.NeedsFinishMigrating;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Separated.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Separated.java
new file mode 100644
index 000000000..bda620df9
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Separated.java
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.PasswordRequired;
+
+
+public class Separated extends State {
+ public Separated(String email, String uid, boolean verified) {
+ super(StateLabel.Separated, email, uid, verified);
+ }
+
+ @Override
+ public void execute(final ExecuteDelegate delegate) {
+ delegate.handleTransition(new PasswordRequired(), this);
+ }
+
+ @Override
+ public Action getNeededAction() {
+ return Action.NeedsPassword;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/State.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/State.java
new file mode 100644
index 000000000..797011ec2
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/State.java
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+
+public abstract class State {
+ public static final long CURRENT_VERSION = 3L;
+
+ public enum StateLabel {
+ Engaged,
+ Cohabiting,
+ Married,
+ Separated,
+ Doghouse,
+ MigratedFromSync11,
+ }
+
+ public enum Action {
+ NeedsUpgrade,
+ NeedsPassword,
+ NeedsVerification,
+ NeedsFinishMigrating,
+ None,
+ }
+
+ protected final StateLabel stateLabel;
+ public final String email;
+ public final String uid;
+ public final boolean verified;
+
+ public State(StateLabel stateLabel, String email, String uid, boolean verified) {
+ Utils.throwIfNull(email, uid);
+ this.stateLabel = stateLabel;
+ this.email = email;
+ this.uid = uid;
+ this.verified = verified;
+ }
+
+ public StateLabel getStateLabel() {
+ return this.stateLabel;
+ }
+
+ public ExtendedJSONObject toJSONObject() {
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put("version", State.CURRENT_VERSION);
+ o.put("email", email);
+ o.put("uid", uid);
+ o.put("verified", verified);
+ return o;
+ }
+
+ public State makeSeparatedState() {
+ return new Separated(email, uid, verified);
+ }
+
+ public State makeDoghouseState() {
+ return new Doghouse(email, uid, verified);
+ }
+
+ public State makeMigratedFromSync11State(String password) {
+ return new MigratedFromSync11(email, uid, verified, password);
+ }
+
+ public abstract void execute(ExecuteDelegate delegate);
+
+ public abstract Action getNeededAction();
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/StateFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/StateFactory.java
new file mode 100644
index 000000000..a98f2fb27
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/StateFactory.java
@@ -0,0 +1,206 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.DSACryptoImplementation;
+import org.mozilla.gecko.browserid.RSACryptoImplementation;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.Utils;
+
+/**
+ * Create {@link State} instances from serialized representations.
+ * <p>
+ * Version 1 recognizes 5 state labels (Engaged, Cohabiting, Married, Separated,
+ * Doghouse). In the Cohabiting and Married states, the associated key pairs are
+ * always RSA key pairs.
+ * <p>
+ * Version 2 is identical to version 1, except that in the Cohabiting and
+ * Married states, the associated keypairs are always DSA key pairs.
+ */
+public class StateFactory {
+ private static final String LOG_TAG = StateFactory.class.getSimpleName();
+
+ private static final int KEY_PAIR_SIZE_IN_BITS_V1 = 1024;
+
+ public static BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException {
+ // New key pairs are always DSA.
+ return DSACryptoImplementation.generateKeyPair(KEY_PAIR_SIZE_IN_BITS_V1);
+ }
+
+ protected static BrowserIDKeyPair keyPairFromJSONObjectV1(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException {
+ // V1 key pairs are RSA.
+ return RSACryptoImplementation.fromJSONObject(o);
+ }
+
+ protected static BrowserIDKeyPair keyPairFromJSONObjectV2(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException {
+ // V2 key pairs are DSA.
+ return DSACryptoImplementation.fromJSONObject(o);
+ }
+
+ public static State fromJSONObject(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException {
+ Long version = o.getLong("version");
+ if (version == null) {
+ throw new IllegalStateException("version must not be null");
+ }
+
+ final int v = version.intValue();
+ if (v == 3) {
+ // The most common case is the most recent version.
+ return fromJSONObjectV3(stateLabel, o);
+ }
+ if (v == 2) {
+ return fromJSONObjectV2(stateLabel, o);
+ }
+ if (v == 1) {
+ final State state = fromJSONObjectV1(stateLabel, o);
+ return migrateV1toV2(stateLabel, state);
+ }
+ throw new IllegalStateException("version must be in {1, 2}");
+ }
+
+ protected static State fromJSONObjectV1(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException {
+ switch (stateLabel) {
+ case Engaged:
+ return new Engaged(
+ o.getString("email"),
+ o.getString("uid"),
+ o.getBoolean("verified"),
+ Utils.hex2Byte(o.getString("unwrapkB")),
+ Utils.hex2Byte(o.getString("sessionToken")),
+ Utils.hex2Byte(o.getString("keyFetchToken")));
+ case Cohabiting:
+ return new Cohabiting(
+ o.getString("email"),
+ o.getString("uid"),
+ Utils.hex2Byte(o.getString("sessionToken")),
+ Utils.hex2Byte(o.getString("kA")),
+ Utils.hex2Byte(o.getString("kB")),
+ keyPairFromJSONObjectV1(o.getObject("keyPair")));
+ case Married:
+ return new Married(
+ o.getString("email"),
+ o.getString("uid"),
+ Utils.hex2Byte(o.getString("sessionToken")),
+ Utils.hex2Byte(o.getString("kA")),
+ Utils.hex2Byte(o.getString("kB")),
+ keyPairFromJSONObjectV1(o.getObject("keyPair")),
+ o.getString("certificate"));
+ case Separated:
+ return new Separated(
+ o.getString("email"),
+ o.getString("uid"),
+ o.getBoolean("verified"));
+ case Doghouse:
+ return new Doghouse(
+ o.getString("email"),
+ o.getString("uid"),
+ o.getBoolean("verified"));
+ default:
+ throw new IllegalStateException("unrecognized state label: " + stateLabel);
+ }
+ }
+
+ /**
+ * Exactly the same as {@link fromJSONObjectV1}, except that all key pairs are DSA key pairs.
+ */
+ protected static State fromJSONObjectV2(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException {
+ switch (stateLabel) {
+ case Cohabiting:
+ return new Cohabiting(
+ o.getString("email"),
+ o.getString("uid"),
+ Utils.hex2Byte(o.getString("sessionToken")),
+ Utils.hex2Byte(o.getString("kA")),
+ Utils.hex2Byte(o.getString("kB")),
+ keyPairFromJSONObjectV2(o.getObject("keyPair")));
+ case Married:
+ return new Married(
+ o.getString("email"),
+ o.getString("uid"),
+ Utils.hex2Byte(o.getString("sessionToken")),
+ Utils.hex2Byte(o.getString("kA")),
+ Utils.hex2Byte(o.getString("kB")),
+ keyPairFromJSONObjectV2(o.getObject("keyPair")),
+ o.getString("certificate"));
+ default:
+ return fromJSONObjectV1(stateLabel, o);
+ }
+ }
+
+ /**
+ * Exactly the same as {@link fromJSONObjectV2}, except that there's a new
+ * MigratedFromSyncV11 state.
+ */
+ protected static State fromJSONObjectV3(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException {
+ switch (stateLabel) {
+ case MigratedFromSync11:
+ return new MigratedFromSync11(
+ o.getString("email"),
+ o.getString("uid"),
+ o.getBoolean("verified"),
+ o.getString("password"));
+ default:
+ return fromJSONObjectV2(stateLabel, o);
+ }
+ }
+
+ protected static void logMigration(State from, State to) {
+ if (!FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ return;
+ }
+ try {
+ FxAccountUtils.pii(LOG_TAG, "V1 persisted state is: " + from.toJSONObject().toJSONString());
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Error producing JSON representation of V1 state.", e);
+ }
+ FxAccountUtils.pii(LOG_TAG, "Generated new V2 state: " + to.toJSONObject().toJSONString());
+ }
+
+ protected static State migrateV1toV2(StateLabel stateLabel, State state) throws NoSuchAlgorithmException {
+ if (state == null) {
+ // This should never happen, but let's be careful.
+ Logger.error(LOG_TAG, "Got null state in migrateV1toV2; returning null.");
+ return state;
+ }
+
+ Logger.info(LOG_TAG, "Migrating V1 persisted State to V2; stateLabel: " + stateLabel);
+
+ // In V1, we use an RSA keyPair. In V2, we use a DSA keyPair. Only
+ // Cohabiting and Married states have a persisted keyPair at all; all
+ // other states need no conversion at all.
+ switch (stateLabel) {
+ case Cohabiting: {
+ // In the Cohabiting state, we can just generate a new key pair and move on.
+ final Cohabiting cohabiting = (Cohabiting) state;
+ final BrowserIDKeyPair keyPair = generateKeyPair();
+ final State migrated = new Cohabiting(cohabiting.email, cohabiting.uid, cohabiting.sessionToken, cohabiting.kA, cohabiting.kB, keyPair);
+ logMigration(cohabiting, migrated);
+ return migrated;
+ }
+ case Married: {
+ // In the Married state, we cannot only change the key pair: the stored
+ // certificate signs the public key of the now obsolete key pair. We
+ // regress to the Cohabiting state; the next time we sync, we should
+ // advance back to Married.
+ final Married married = (Married) state;
+ final BrowserIDKeyPair keyPair = generateKeyPair();
+ final State migrated = new Cohabiting(married.email, married.uid, married.sessionToken, married.kA, married.kB, keyPair);
+ logMigration(married, migrated);
+ return migrated;
+ }
+ default:
+ // Otherwise, V1 and V2 states are identical.
+ return state;
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/TokensAndKeysState.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/TokensAndKeysState.java
new file mode 100644
index 000000000..b5121a4d4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/TokensAndKeysState.java
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.login;
+
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+
+public abstract class TokensAndKeysState extends State {
+ protected final byte[] sessionToken;
+ protected final byte[] kA;
+ protected final byte[] kB;
+ protected final BrowserIDKeyPair keyPair;
+
+ public TokensAndKeysState(StateLabel stateLabel, String email, String uid, byte[] sessionToken, byte[] kA, byte[] kB, BrowserIDKeyPair keyPair) {
+ super(stateLabel, email, uid, true);
+ Utils.throwIfNull(sessionToken, kA, kB, keyPair);
+ this.sessionToken = sessionToken;
+ this.kA = kA;
+ this.kB = kB;
+ this.keyPair = keyPair;
+ }
+
+ @Override
+ public ExtendedJSONObject toJSONObject() {
+ ExtendedJSONObject o = super.toJSONObject();
+ // Fields are non-null by constructor.
+ o.put("sessionToken", Utils.byte2Hex(sessionToken));
+ o.put("kA", Utils.byte2Hex(kA));
+ o.put("kB", Utils.byte2Hex(kB));
+ o.put("keyPair", keyPair.toJSONObject());
+ return o;
+ }
+
+ public byte[] getSessionToken() {
+ return sessionToken;
+ }
+
+ @Override
+ public Action getNeededAction() {
+ return Action.None;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java
new file mode 100644
index 000000000..60a63a5e1
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java
@@ -0,0 +1,154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.receivers;
+
+import android.app.IntentService;
+import android.content.Context;
+import android.content.Intent;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.sync.FxAccountNotificationManager;
+import org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter;
+import org.mozilla.gecko.sync.repositories.android.ClientsDatabase;
+import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository;
+
+import java.util.concurrent.Executor;
+
+/**
+ * A background service to clean up after a Firefox Account is deleted.
+ * <p>
+ * Note that we specifically handle deleting the pickle file using a Service and a
+ * BroadcastReceiver, rather than a background thread, to allow channels sharing a Firefox account
+ * to delete their respective pickle files (since, if one remains, the account will be restored
+ * when that channel is used).
+ */
+public class FxAccountDeletedService extends IntentService {
+ public static final String LOG_TAG = FxAccountDeletedService.class.getSimpleName();
+
+ public FxAccountDeletedService() {
+ super(LOG_TAG);
+ }
+
+ @Override
+ protected void onHandleIntent(final Intent intent) {
+ // We have an in-memory accounts cache which we use for a variety of tasks; it needs to be cleared.
+ // It should be fine to invalidate it before doing anything else, as the tasks below do not rely
+ // on this data.
+ AndroidFxAccount.invalidateCaches();
+
+ // Intent can, in theory, be null. Bug 1025937.
+ if (intent == null) {
+ Logger.debug(LOG_TAG, "Short-circuiting on null intent.");
+ return;
+ }
+
+ final Context context = this;
+
+ long intentVersion = intent.getLongExtra(
+ FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION_KEY, 0);
+ long expectedVersion = FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION;
+ if (intentVersion != expectedVersion) {
+ Logger.warn(LOG_TAG, "Intent malformed: version " + intentVersion + " given but " +
+ "version " + expectedVersion + "expected. Not cleaning up after deleted Account.");
+ return;
+ }
+
+ // Android Account name, not Sync encoded account name.
+ final String accountName = intent.getStringExtra(
+ FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY);
+ if (accountName == null) {
+ Logger.warn(LOG_TAG, "Intent malformed: no account name given. Not cleaning up after " +
+ "deleted Account.");
+ return;
+ }
+
+
+ // Fire up gecko and unsubscribe push
+ final Intent geckoIntent = new Intent();
+ geckoIntent.setAction("create-services");
+ geckoIntent.setClassName(context, "org.mozilla.gecko.GeckoService");
+ geckoIntent.putExtra("category", "android-push-service");
+ geckoIntent.putExtra("data", "android-fxa-unsubscribe");
+ final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
+ geckoIntent.putExtra("org.mozilla.gecko.intent.PROFILE_NAME",
+ intent.getStringExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_PROFILE));
+ context.startService(geckoIntent);
+
+ // Delete client database and non-local tabs.
+ Logger.info(LOG_TAG, "Deleting the entire Fennec clients database and non-local tabs");
+ FennecTabsRepository.deleteNonLocalClientsAndTabs(context);
+
+
+ // Clear Firefox Sync client tables.
+ try {
+ Logger.info(LOG_TAG, "Deleting the Firefox Sync clients database.");
+ ClientsDatabase db = null;
+ try {
+ db = new ClientsDatabase(context);
+ db.wipeClientsTable();
+ db.wipeCommandsTable();
+ } finally {
+ if (db != null) {
+ db.close();
+ }
+ }
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception deleting the Firefox Sync clients database; ignoring.", e);
+ }
+
+ // Remove any displayed notifications.
+ new FxAccountNotificationManager(FxAccountSyncAdapter.NOTIFICATION_ID).clear(context);
+
+ // Bug 1147275: Delete cached oauth tokens. There's no way to query all
+ // oauth tokens from Android, so this is tricky to do comprehensively. We
+ // can query, individually, for specific oauth tokens to delete, however.
+ final String oauthServerURI = intent.getStringExtra(FxAccountConstants.ACCOUNT_OAUTH_SERVICE_ENDPOINT_KEY);
+ final String[] tokens = intent.getStringArrayExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_AUTH_TOKENS);
+ if (oauthServerURI != null && tokens != null) {
+ final Executor directExecutor = new Executor() {
+ @Override
+ public void execute(Runnable runnable) {
+ runnable.run();
+ }
+ };
+
+ final FxAccountOAuthClient10 oauthClient = new FxAccountOAuthClient10(oauthServerURI, directExecutor);
+
+ for (String token : tokens) {
+ if (token == null) {
+ Logger.error(LOG_TAG, "Cached OAuth token is null; should never happen. Ignoring.");
+ continue;
+ }
+ try {
+ oauthClient.deleteToken(token, new FxAccountAbstractClient.RequestDelegate<Void>() {
+ @Override
+ public void handleSuccess(Void result) {
+ Logger.info(LOG_TAG, "Successfully deleted cached OAuth token.");
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ Logger.error(LOG_TAG, "Failed to delete cached OAuth token; ignoring.", e);
+ }
+
+ @Override
+ public void handleFailure(FxAccountAbstractClientRemoteException e) {
+ Logger.error(LOG_TAG, "Exception during cached OAuth token deletion; ignoring.", e);
+ }
+ });
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Exception during cached OAuth token deletion; ignoring.", e);
+ }
+ }
+ } else {
+ Logger.error(LOG_TAG, "Cached OAuth server URI is null or cached OAuth tokens are null; ignoring.");
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountUpgradeReceiver.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountUpgradeReceiver.java
new file mode 100644
index 000000000..ad81e0488
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountUpgradeReceiver.java
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.receivers;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.sync.Utils;
+
+import android.accounts.Account;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * A receiver that takes action when our Android package is upgraded (replaced).
+ */
+public class FxAccountUpgradeReceiver extends BroadcastReceiver {
+ private static final String LOG_TAG = FxAccountUpgradeReceiver.class.getSimpleName();
+
+ /**
+ * Produce a list of Runnable instances to be executed sequentially on
+ * upgrade.
+ * <p>
+ * Each Runnable will be executed sequentially on a background thread. Any
+ * unchecked Exception thrown will be caught and ignored.
+ *
+ * @param context Android context.
+ * @return list of Runnable instances.
+ */
+ protected List<Runnable> onUpgradeRunnables(Context context) {
+ List<Runnable> runnables = new LinkedList<Runnable>();
+ runnables.add(new MaybeUnpickleRunnable(context));
+ // Recovering accounts that are in the Doghouse should happen *after* we
+ // unpickle any accounts saved to disk.
+ runnables.add(new AdvanceFromDoghouseRunnable(context));
+ return runnables;
+ }
+
+ @Override
+ public void onReceive(final Context context, Intent intent) {
+ Logger.setThreadLogTag(FxAccountConstants.GLOBAL_LOG_TAG);
+ Logger.info(LOG_TAG, "Upgrade broadcast received.");
+
+ // Iterate Runnable instances one at a time.
+ final Executor executor = Executors.newSingleThreadExecutor();
+ for (final Runnable runnable : onUpgradeRunnables(context)) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ runnable.run();
+ } catch (Exception e) {
+ // We really don't want to throw on a background thread, so we
+ // catch, log, and move on.
+ Logger.error(LOG_TAG, "Got exception executing background upgrade Runnable; ignoring.", e);
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * A Runnable that tries to unpickle any pickled Firefox Accounts.
+ */
+ protected static class MaybeUnpickleRunnable implements Runnable {
+ protected final Context context;
+
+ public MaybeUnpickleRunnable(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public void run() {
+ // Querying the accounts will unpickle any pickled Firefox Account.
+ Logger.info(LOG_TAG, "Trying to unpickle any pickled Firefox Account.");
+ FirefoxAccounts.getFirefoxAccounts(context);
+ }
+ }
+
+ /**
+ * A Runnable that tries to advance existing Firefox Accounts that are in the
+ * Doghouse state to the Separated state.
+ * <p>
+ * This is our main deprecation-and-upgrade mechanism: in some way, the
+ * Account gets moved to the Doghouse state. If possible, an upgraded version
+ * of the package advances to Separated, prompting the user to re-connect the
+ * Account.
+ */
+ protected static class AdvanceFromDoghouseRunnable implements Runnable {
+ protected final Context context;
+
+ public AdvanceFromDoghouseRunnable(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public void run() {
+ final Account[] accounts = FirefoxAccounts.getFirefoxAccounts(context);
+ Logger.info(LOG_TAG, "Trying to advance " + accounts.length + " existing Firefox Accounts from the Doghouse to Separated (if necessary).");
+ for (Account account : accounts) {
+ try {
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+ // For great debugging.
+ if (FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ fxAccount.dump();
+ }
+ State state = fxAccount.getState();
+ if (state == null || state.getStateLabel() != StateLabel.Doghouse) {
+ Logger.debug(LOG_TAG, "Account named like " + Utils.obfuscateEmail(account.name) + " is not in the Doghouse; skipping.");
+ continue;
+ }
+ Logger.debug(LOG_TAG, "Account named like " + Utils.obfuscateEmail(account.name) + " is in the Doghouse; advancing to Separated.");
+ fxAccount.setState(state.makeSeparatedState());
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception trying to advance account named like " + Utils.obfuscateEmail(account.name) +
+ " from Doghouse to Separated state; ignoring.", e);
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountNotificationManager.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountNotificationManager.java
new file mode 100644
index 000000000..b44da76fc
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountNotificationManager.java
@@ -0,0 +1,114 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.sync;
+
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationCompat.Builder;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.common.telemetry.TelemetryWrapper;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.activities.FxAccountWebFlowActivity;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.login.State.Action;
+import org.mozilla.gecko.sync.telemetry.TelemetryContract;
+
+/**
+ * Abstraction that manages notifications shown or hidden for a Firefox Account.
+ * <p>
+ * In future, we anticipate this tracking things like:
+ * <ul>
+ * <li>new engines to offer to Sync;</li>
+ * <li>service interruption updates;</li>
+ * <li>messages from other clients.</li>
+ * </ul>
+ */
+public class FxAccountNotificationManager {
+ private static final String LOG_TAG = FxAccountNotificationManager.class.getSimpleName();
+
+ protected final int notificationId;
+
+ // We're lazy about updating our locale info, because most syncs don't notify.
+ private volatile boolean localeUpdated;
+
+ public FxAccountNotificationManager(int notificationId) {
+ this.notificationId = notificationId;
+ }
+
+ /**
+ * Remove all Firefox Account related notifications from the notification manager.
+ *
+ * @param context
+ * Android context.
+ */
+ public void clear(Context context) {
+ final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.cancel(notificationId);
+ }
+
+ /**
+ * Reflect new Firefox Account state to the notification manager: show or hide
+ * notifications reflecting the state of a Firefox Account.
+ *
+ * @param context
+ * Android context.
+ * @param fxAccount
+ * Firefox Account to reflect to the notification manager.
+ */
+ public void update(Context context, AndroidFxAccount fxAccount) {
+ final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+
+ final State state = fxAccount.getState();
+ final Action action = state.getNeededAction();
+ if (action == Action.None) {
+ Logger.info(LOG_TAG, "State " + state.getStateLabel() + " needs no action; cancelling any existing notification.");
+ notificationManager.cancel(notificationId);
+ return;
+ }
+
+ if (!localeUpdated) {
+ localeUpdated = true;
+ Locales.getLocaleManager().getAndApplyPersistedLocale(context);
+ }
+
+ final String title;
+ final String text;
+ final Intent notificationIntent;
+ if (action == Action.NeedsFinishMigrating) {
+ TelemetryWrapper.addToHistogram(TelemetryContract.SYNC11_MIGRATION_NOTIFICATIONS_OFFERED, 1);
+
+ title = context.getResources().getString(R.string.fxaccount_sync_finish_migrating_notification_title);
+ text = context.getResources().getString(R.string.fxaccount_sync_finish_migrating_notification_text, state.email);
+ notificationIntent = new Intent(FxAccountConstants.ACTION_FXA_FINISH_MIGRATING);
+ } else {
+ title = context.getResources().getString(R.string.fxaccount_sync_sign_in_error_notification_title);
+ text = context.getResources().getString(R.string.fxaccount_sync_sign_in_error_notification_text, state.email);
+ notificationIntent = new Intent(FxAccountConstants.ACTION_FXA_STATUS);
+ }
+
+ notificationIntent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_NOTIFICATION);
+
+ Logger.info(LOG_TAG, "State " + state.getStateLabel() + " needs action; offering notification with title: " + title);
+ FxAccountUtils.pii(LOG_TAG, "And text: " + text);
+
+ final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, notificationIntent, 0);
+
+ final Builder builder = new NotificationCompat.Builder(context);
+ builder
+ .setContentTitle(title)
+ .setContentText(text)
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent);
+ notificationManager.notify(notificationId, builder.build());
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountProfileService.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountProfileService.java
new file mode 100644
index 000000000..7f03eff1c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountProfileService.java
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.sync;
+
+import android.accounts.AccountManager;
+import android.app.Activity;
+import android.app.IntentService;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.ResultReceiver;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient;
+import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException;
+import org.mozilla.gecko.background.fxa.profile.FxAccountProfileClient10;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+public class FxAccountProfileService extends IntentService {
+ private static final String LOG_TAG = "FxAccountProfileService";
+ private static final Executor EXECUTOR_SERVICE = Executors.newSingleThreadExecutor();
+ public static final String KEY_AUTH_TOKEN = "auth_token";
+ public static final String KEY_PROFILE_SERVER_URI = "profileServerURI";
+ public static final String KEY_RESULT_RECEIVER = "resultReceiver";
+ public static final String KEY_RESULT_STRING = "RESULT_STRING";
+
+ public FxAccountProfileService() {
+ super("FxAccountProfileService");
+ }
+
+ @Override
+ protected void onHandleIntent(Intent intent) {
+ final String authToken = intent.getStringExtra(KEY_AUTH_TOKEN);
+ final String profileServerURI = intent.getStringExtra(KEY_PROFILE_SERVER_URI);
+ final ResultReceiver resultReceiver = intent.getParcelableExtra(KEY_RESULT_RECEIVER);
+
+ if (resultReceiver == null) {
+ Logger.warn(LOG_TAG, "Result receiver must not be null; ignoring intent.");
+ return;
+ }
+
+ if (authToken == null || authToken.length() == 0) {
+ Logger.warn(LOG_TAG, "Invalid Auth Token");
+ sendResult("Invalid Auth Token", resultReceiver, Activity.RESULT_CANCELED);
+ return;
+ }
+
+ if (profileServerURI == null || profileServerURI.length() == 0) {
+ Logger.warn(LOG_TAG, "Invalid profile Server Endpoint");
+ sendResult("Invalid profile Server Endpoint", resultReceiver, Activity.RESULT_CANCELED);
+ return;
+ }
+
+ // This delegate fetches the profile avatar json.
+ FxAccountProfileClient10.RequestDelegate<ExtendedJSONObject> delegate = new FxAccountAbstractClient.RequestDelegate<ExtendedJSONObject>() {
+ @Override
+ public void handleError(Exception e) {
+ Logger.error(LOG_TAG, "Error fetching Account profile.", e);
+ sendResult("Error fetching Account profile.", resultReceiver, Activity.RESULT_CANCELED);
+ }
+
+ @Override
+ public void handleFailure(FxAccountAbstractClientException.FxAccountAbstractClientRemoteException e) {
+ Logger.warn(LOG_TAG, "Failed to fetch Account profile.", e);
+
+ if (e.isInvalidAuthentication()) {
+ // The profile server rejected the cached oauth token! Invalidate it.
+ // A new token will be generated upon next request.
+ Logger.info(LOG_TAG, "Invalidating oauth token after 401!");
+ AccountManager.get(FxAccountProfileService.this).invalidateAuthToken(FxAccountConstants.ACCOUNT_TYPE, authToken);
+ }
+
+ sendResult("Failed to fetch Account profile.", resultReceiver, Activity.RESULT_CANCELED);
+ }
+
+ @Override
+ public void handleSuccess(ExtendedJSONObject result) {
+ if (result != null){
+ FxAccountUtils.pii(LOG_TAG, "Profile server return profile: " + result.toJSONString());
+ sendResult(result.toJSONString(), resultReceiver, Activity.RESULT_OK);
+ }
+ }
+ };
+
+ FxAccountProfileClient10 client = new FxAccountProfileClient10(profileServerURI, EXECUTOR_SERVICE);
+ try {
+ client.profile(authToken, delegate);
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Got exception fetching profile.", e);
+ delegate.handleError(e);
+ }
+ }
+
+ private void sendResult(final String result, final ResultReceiver resultReceiver, final int code) {
+ if (resultReceiver != null) {
+ final Bundle bundle = new Bundle();
+ bundle.putString(KEY_RESULT_STRING, result);
+ resultReceiver.send(code, bundle);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSchedulePolicy.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSchedulePolicy.java
new file mode 100644
index 000000000..708686e72
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSchedulePolicy.java
@@ -0,0 +1,178 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.sync;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.State.Action;
+import org.mozilla.gecko.sync.BackoffHandler;
+
+import android.accounts.Account;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.os.Bundle;
+
+public class FxAccountSchedulePolicy implements SchedulePolicy {
+ private static final String LOG_TAG = "FxAccountSchedulePolicy";
+
+ // Our poll intervals are used to trigger automatic background syncs
+ // in the absence of user activity.
+ //
+ // We also receive sync requests as a result of network tickles, so
+ // these intervals are long, with the exception of the rapid polling
+ // while we wait for verification: if we're waiting for the user to
+ // click on a verification link, we sync very often in order to detect
+ // a change in state.
+ //
+ // In the case of unverified -> unverified (no transition), this should be
+ // very close to a single HTTP request (with the SyncAdapter overhead, of
+ // course, but that's not wildly different from alarm manager overhead).
+ //
+ // The /account/status endpoint is HAWK authed by sessionToken, so we still
+ // have to do some crypto no matter what.
+
+ // TODO: only do this for a while...
+ public static final long POLL_INTERVAL_PENDING_VERIFICATION = 60; // 1 minute.
+
+ // If we're in some kind of error state, there's no point trying often.
+ // This is not the same as a server-imposed backoff, which will be
+ // reflected dynamically.
+ public static final long POLL_INTERVAL_ERROR_STATE_SEC = 24 * 60 * 60; // 24 hours.
+
+ // If we're the only device, just sync once or twice a day in case that
+ // changes.
+ public static final long POLL_INTERVAL_SINGLE_DEVICE_SEC = 18 * 60 * 60; // 18 hours.
+
+ // And if we know there are other devices, let's sync often enough that
+ // we'll be more likely to be caught up (even if not completely) by the
+ // time you next use this device. This is also achieved via Android's
+ // network tickles.
+ public static final long POLL_INTERVAL_MULTI_DEVICE_SEC = 12 * 60 * 60; // 12 hours.
+
+ // This is used solely as an optimization for backoff handling, so it's not
+ // persisted.
+ private static volatile long POLL_INTERVAL_CURRENT_SEC = POLL_INTERVAL_SINGLE_DEVICE_SEC;
+
+ // Never sync more frequently than this, unless forced.
+ // This is to avoid overly-frequent syncs during active browsing.
+ public static final long RATE_LIMIT_FUNDAMENTAL_SEC = 90; // 90 seconds.
+
+ /**
+ * We are prompted to sync by several inputs:
+ * * Periodic syncs that we schedule at long intervals. See the POLL constants.
+ * * Network-tickle-based syncs that Android starts.
+ * * Upload-only syncs that are caused by local database writes.
+ *
+ * We rate-limit periodic and network-sourced events with this constant.
+ * We rate limit <b>both</b> with {@link FxAccountSchedulePolicy#RATE_LIMIT_FUNDAMENTAL_SEC}.
+ */
+ public static final long RATE_LIMIT_BACKGROUND_SEC = 60 * 60; // 1 hour.
+
+ private final AndroidFxAccount account;
+ private final Context context;
+
+ public FxAccountSchedulePolicy(Context context, AndroidFxAccount account) {
+ this.account = account;
+ this.context = context;
+ }
+
+ /**
+ * Return a millisecond timestamp in the future, offset from the current
+ * time by the provided amount.
+ * @param millis the duration by which to delay
+ * @return a timestamp.
+ */
+ private static long delay(long millis) {
+ return System.currentTimeMillis() + millis;
+ }
+
+ /**
+ * Updates the existing system periodic sync interval to the specified duration.
+ *
+ * @param intervalSeconds the requested period, which Android will vary by up to 4%.
+ */
+ protected void requestPeriodicSync(final long intervalSeconds) {
+ final String authority = BrowserContract.AUTHORITY;
+ final Account account = this.account.getAndroidAccount();
+ this.context.getContentResolver();
+ Logger.info(LOG_TAG, "Scheduling periodic sync for " + intervalSeconds + ".");
+ ContentResolver.addPeriodicSync(account, authority, Bundle.EMPTY, intervalSeconds);
+ POLL_INTERVAL_CURRENT_SEC = intervalSeconds;
+ }
+
+ @Override
+ public void onSuccessfulSync(int otherClientsCount) {
+ this.account.setLastSyncedTimestamp(System.currentTimeMillis());
+ // This undoes the change made in observeBackoffMillis -- once we hit backoff we'll
+ // periodically sync at the backoff duration, but as soon as we succeed we'll switch
+ // into the client-count-dependent interval.
+ long interval = (otherClientsCount > 0) ? POLL_INTERVAL_MULTI_DEVICE_SEC : POLL_INTERVAL_SINGLE_DEVICE_SEC;
+ requestPeriodicSync(interval);
+ }
+
+ @Override
+ public void onHandleFinal(Action needed) {
+ switch (needed) {
+ case NeedsPassword:
+ case NeedsUpgrade:
+ case NeedsFinishMigrating:
+ requestPeriodicSync(POLL_INTERVAL_ERROR_STATE_SEC);
+ break;
+ case NeedsVerification:
+ requestPeriodicSync(POLL_INTERVAL_PENDING_VERIFICATION);
+ break;
+ case None:
+ // No action needed: we'll set the periodic sync interval
+ // when the sync finishes, via the SessionCallback.
+ break;
+ }
+ }
+
+ @Override
+ public void onUpgradeRequired() {
+ // TODO: this shouldn't occur in FxA, but when we upgrade we
+ // need to reduce the interval again.
+ requestPeriodicSync(POLL_INTERVAL_ERROR_STATE_SEC);
+ }
+
+ @Override
+ public void onUnauthorized() {
+ // TODO: this shouldn't occur in FxA, but when we fix our credentials
+ // we need to reduce the interval again.
+ requestPeriodicSync(POLL_INTERVAL_ERROR_STATE_SEC);
+ }
+
+ @Override
+ public void configureBackoffMillisOnBackoff(BackoffHandler backoffHandler, long backoffMillis, boolean onlyExtend) {
+ if (onlyExtend) {
+ backoffHandler.extendEarliestNextRequest(delay(backoffMillis));
+ } else {
+ backoffHandler.setEarliestNextRequest(delay(backoffMillis));
+ }
+
+ // Yes, we might be part-way through the interval, in which case the backoff
+ // code will do its job. But we certainly don't want to reduce the interval
+ // if we're given a small backoff instruction.
+ // We'll reset the poll interval next time we sync without a backoff instruction.
+ if (backoffMillis > (POLL_INTERVAL_CURRENT_SEC * 1000)) {
+ // Slightly inflate the backoff duration to ensure that a fuzzed
+ // periodic sync doesn't occur before our backoff has passed. Android
+ // 19+ default to a 4% fuzz factor.
+ requestPeriodicSync((long) Math.ceil((1.05 * backoffMillis) / 1000));
+ }
+ }
+
+ /**
+ * Accepts two {@link BackoffHandler} instances as input. These are used
+ * respectively to track fundamental rate limiting, and to separately
+ * rate-limit periodic and network-tickled syncs.
+ */
+ @Override
+ public void configureBackoffMillisBeforeSyncing(BackoffHandler fundamentalRateHandler, BackoffHandler backgroundRateHandler) {
+ fundamentalRateHandler.setEarliestNextRequest(delay(RATE_LIMIT_FUNDAMENTAL_SEC * 1000));
+ backgroundRateHandler.setEarliestNextRequest(delay(RATE_LIMIT_BACKGROUND_SEC * 1000));
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
new file mode 100644
index 000000000..30990cf7f
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
@@ -0,0 +1,568 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.sync;
+
+import android.accounts.Account;
+import android.content.AbstractThreadedSyncAdapter;
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.SyncResult;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.text.TextUtils;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.common.telemetry.TelemetryWrapper;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.background.fxa.SkewHandler;
+import org.mozilla.gecko.browserid.JSONWebTokenUtils;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.FxAccountDeviceRegistrator;
+import org.mozilla.gecko.fxa.authenticator.AccountPickler;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.authenticator.FxADefaultLoginStateMachineDelegate;
+import org.mozilla.gecko.fxa.authenticator.FxAccountAuthenticator;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine;
+import org.mozilla.gecko.fxa.login.Married;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.fxa.sync.FxAccountSyncDelegate.Result;
+import org.mozilla.gecko.sync.BackoffHandler;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.PrefsBackoffHandler;
+import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.ThreadPool;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
+import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.HawkAuthHeaderProvider;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+import org.mozilla.gecko.sync.telemetry.TelemetryContract;
+import org.mozilla.gecko.tokenserver.TokenServerClient;
+import org.mozilla.gecko.tokenserver.TokenServerClientDelegate;
+import org.mozilla.gecko.tokenserver.TokenServerException;
+import org.mozilla.gecko.tokenserver.TokenServerToken;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+
+public class FxAccountSyncAdapter extends AbstractThreadedSyncAdapter {
+ private static final String LOG_TAG = FxAccountSyncAdapter.class.getSimpleName();
+
+ public static final int NOTIFICATION_ID = LOG_TAG.hashCode();
+
+ // Tracks the last seen storage hostname for backoff purposes.
+ private static final String PREF_BACKOFF_STORAGE_HOST = "backoffStorageHost";
+
+ // Used to do cheap in-memory rate limiting. Don't sync again if we
+ // successfully synced within this duration.
+ private static final int MINIMUM_SYNC_DELAY_MILLIS = 15 * 1000; // 15 seconds.
+ private volatile long lastSyncRealtimeMillis;
+
+ protected final ExecutorService executor;
+ protected final FxAccountNotificationManager notificationManager;
+
+ public FxAccountSyncAdapter(Context context, boolean autoInitialize) {
+ super(context, autoInitialize);
+ this.executor = Executors.newSingleThreadExecutor();
+ this.notificationManager = new FxAccountNotificationManager(NOTIFICATION_ID);
+ }
+
+ protected static class SyncDelegate extends FxAccountSyncDelegate {
+ @Override
+ public void handleSuccess() {
+ Logger.info(LOG_TAG, "Sync succeeded.");
+ super.handleSuccess();
+ TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_COMPLETED, 1);
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ Logger.error(LOG_TAG, "Got exception syncing.", e);
+ super.handleError(e);
+ TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_FAILED, 1);
+ }
+
+ @Override
+ public void handleCannotSync(State finalState) {
+ Logger.warn(LOG_TAG, "Cannot sync from state: " + finalState.getStateLabel());
+ super.handleCannotSync(finalState);
+ }
+
+ @Override
+ public void postponeSync(long millis) {
+ if (millis <= 0) {
+ Logger.debug(LOG_TAG, "Asked to postpone sync, but zero delay.");
+ }
+ super.postponeSync(millis);
+ }
+
+ @Override
+ public void rejectSync() {
+ super.rejectSync();
+ }
+
+ protected final Collection<String> stageNamesToSync;
+
+ public SyncDelegate(BlockingQueue<Result> latch, SyncResult syncResult, AndroidFxAccount fxAccount, Collection<String> stageNamesToSync) {
+ super(latch, syncResult);
+ this.stageNamesToSync = Collections.unmodifiableCollection(stageNamesToSync);
+ }
+
+ public Collection<String> getStageNamesToSync() {
+ return this.stageNamesToSync;
+ }
+ }
+
+ protected static class SessionCallback implements GlobalSessionCallback {
+ protected final SyncDelegate syncDelegate;
+ protected final SchedulePolicy schedulePolicy;
+ protected volatile BackoffHandler storageBackoffHandler;
+
+ public SessionCallback(SyncDelegate syncDelegate, SchedulePolicy schedulePolicy) {
+ this.syncDelegate = syncDelegate;
+ this.schedulePolicy = schedulePolicy;
+ }
+
+ public void setBackoffHandler(BackoffHandler backoffHandler) {
+ this.storageBackoffHandler = backoffHandler;
+ }
+
+ @Override
+ public boolean shouldBackOffStorage() {
+ return storageBackoffHandler.delayMilliseconds() > 0;
+ }
+
+ @Override
+ public void requestBackoff(long backoffMillis) {
+ final boolean onlyExtend = true; // Because we trust what the storage server says.
+ schedulePolicy.configureBackoffMillisOnBackoff(storageBackoffHandler, backoffMillis, onlyExtend);
+ }
+
+ @Override
+ public void informUpgradeRequiredResponse(GlobalSession session) {
+ schedulePolicy.onUpgradeRequired();
+ }
+
+ @Override
+ public void informUnauthorizedResponse(GlobalSession globalSession, URI oldClusterURL) {
+ schedulePolicy.onUnauthorized();
+ }
+
+ @Override
+ public void informMigrated(GlobalSession globalSession) {
+ // It's not possible to migrate a Firefox Account to another Account type
+ // yet. Yell loudly but otherwise ignore.
+ Logger.error(LOG_TAG,
+ "Firefox Account informMigrated called, but it's not yet possible to migrate. " +
+ "Ignoring even though something is terribly wrong.");
+ }
+
+ @Override
+ public void handleStageCompleted(Stage currentState, GlobalSession globalSession) {
+ }
+
+ @Override
+ public void handleSuccess(GlobalSession globalSession) {
+ Logger.info(LOG_TAG, "Global session succeeded.");
+
+ // Get the number of clients, so we can schedule the sync interval accordingly.
+ try {
+ int otherClientsCount = globalSession.getClientsDelegate().getClientsCount();
+ Logger.debug(LOG_TAG, "" + otherClientsCount + " other client(s).");
+ this.schedulePolicy.onSuccessfulSync(otherClientsCount);
+ } finally {
+ // Continue with the usual success flow.
+ syncDelegate.handleSuccess();
+ }
+ }
+
+ @Override
+ public void handleError(GlobalSession globalSession, Exception e) {
+ Logger.warn(LOG_TAG, "Global session failed."); // Exception will be dumped by delegate below.
+ syncDelegate.handleError(e);
+ // TODO: should we reduce the periodic sync interval?
+ }
+
+ @Override
+ public void handleAborted(GlobalSession globalSession, String reason) {
+ Logger.warn(LOG_TAG, "Global session aborted: " + reason);
+ syncDelegate.handleError(null);
+ // TODO: should we reduce the periodic sync interval?
+ }
+ };
+
+ /**
+ * Return true if the provided {@link BackoffHandler} isn't reporting that we're in
+ * a backoff state, or the provided {@link Bundle} contains flags that indicate
+ * we should force a sync.
+ */
+ private boolean shouldPerformSync(final BackoffHandler backoffHandler, final String kind, final Bundle extras) {
+ final long delay = backoffHandler.delayMilliseconds();
+ if (delay <= 0) {
+ return true;
+ }
+
+ if (extras == null) {
+ return false;
+ }
+
+ final boolean forced = extras.getBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, false);
+ if (forced) {
+ Logger.info(LOG_TAG, "Forced sync (" + kind + "): overruling remaining backoff of " + delay + "ms.");
+ } else {
+ Logger.info(LOG_TAG, "Not syncing (" + kind + "): must wait another " + delay + "ms.");
+ }
+ return forced;
+ }
+
+ protected void syncWithAssertion(final String audience,
+ final String assertion,
+ final URI tokenServerEndpointURI,
+ final BackoffHandler tokenBackoffHandler,
+ final SharedPreferences sharedPrefs,
+ final KeyBundle syncKeyBundle,
+ final String clientState,
+ final SessionCallback callback,
+ final Bundle extras,
+ final AndroidFxAccount fxAccount) {
+ final TokenServerClientDelegate delegate = new TokenServerClientDelegate() {
+ private boolean didReceiveBackoff = false;
+
+ @Override
+ public String getUserAgent() {
+ return FxAccountConstants.USER_AGENT;
+ }
+
+ @Override
+ public void handleSuccess(final TokenServerToken token) {
+ FxAccountUtils.pii(LOG_TAG, "Got token! uid is " + token.uid + " and endpoint is " + token.endpoint + ".");
+ fxAccount.releaseSharedAccountStateLock();
+
+ if (!didReceiveBackoff) {
+ // We must be OK to touch this token server.
+ tokenBackoffHandler.setEarliestNextRequest(0L);
+ }
+
+ final URI storageServerURI;
+ try {
+ storageServerURI = new URI(token.endpoint);
+ } catch (URISyntaxException e) {
+ handleError(e);
+ return;
+ }
+ final String storageHostname = storageServerURI.getHost();
+
+ // We back off on a per-host basis. When we have an endpoint URI from a token, we
+ // can check on the backoff status for that host.
+ // If we're supposed to be backing off, we abort the not-yet-started session.
+ final BackoffHandler storageBackoffHandler = new PrefsBackoffHandler(sharedPrefs, "sync.storage");
+ callback.setBackoffHandler(storageBackoffHandler);
+
+ String lastStorageHost = sharedPrefs.getString(PREF_BACKOFF_STORAGE_HOST, null);
+ final boolean storageHostIsUnchanged = lastStorageHost != null &&
+ lastStorageHost.equalsIgnoreCase(storageHostname);
+ if (storageHostIsUnchanged) {
+ Logger.debug(LOG_TAG, "Storage host is unchanged.");
+ if (!shouldPerformSync(storageBackoffHandler, "storage", extras)) {
+ Logger.info(LOG_TAG, "Not syncing: storage server requested backoff.");
+ callback.handleAborted(null, "Storage backoff");
+ return;
+ }
+ } else {
+ Logger.debug(LOG_TAG, "Received new storage host.");
+ }
+
+ // Invalidate the previous backoff, because our storage host has changed,
+ // or we never had one at all, or we're OK to sync.
+ storageBackoffHandler.setEarliestNextRequest(0L);
+
+ GlobalSession globalSession = null;
+ try {
+ final ClientsDataDelegate clientsDataDelegate = new SharedPreferencesClientsDataDelegate(sharedPrefs, getContext());
+ if (FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ FxAccountUtils.pii(LOG_TAG, "Client device name is: '" + clientsDataDelegate.getClientName() + "'.");
+ FxAccountUtils.pii(LOG_TAG, "Client device data last modified: " + clientsDataDelegate.getLastModifiedTimestamp());
+ }
+
+ // We compute skew over time using SkewHandler. This yields an unchanging
+ // skew adjustment that the HawkAuthHeaderProvider uses to adjust its
+ // timestamps. Eventually we might want this to adapt within the scope of a
+ // global session.
+ final SkewHandler storageServerSkewHandler = SkewHandler.getSkewHandlerForHostname(storageHostname);
+ final long storageServerSkew = storageServerSkewHandler.getSkewInSeconds();
+ // We expect Sync to upload large sets of records. Calculating the
+ // payload verification hash for these record sets could be expensive,
+ // so we explicitly do not send payload verification hashes to the
+ // Sync storage endpoint.
+ final boolean includePayloadVerificationHash = false;
+ final AuthHeaderProvider authHeaderProvider = new HawkAuthHeaderProvider(token.id, token.key.getBytes("UTF-8"), includePayloadVerificationHash, storageServerSkew);
+
+ final Context context = getContext();
+ final SyncConfiguration syncConfig = new SyncConfiguration(token.uid, authHeaderProvider, sharedPrefs, syncKeyBundle);
+
+ Collection<String> knownStageNames = SyncConfiguration.validEngineNames();
+ syncConfig.stagesToSync = Utils.getStagesToSyncFromBundle(knownStageNames, extras);
+ syncConfig.setClusterURL(storageServerURI);
+
+ globalSession = new GlobalSession(syncConfig, callback, context, clientsDataDelegate);
+ globalSession.start();
+ } catch (Exception e) {
+ callback.handleError(globalSession, e);
+ return;
+ }
+ }
+
+ @Override
+ public void handleFailure(TokenServerException e) {
+ Logger.error(LOG_TAG, "Failed to get token.", e);
+ try {
+ // We should only get here *after* we're locked into the married state.
+ State state = fxAccount.getState();
+ if (state.getStateLabel() == StateLabel.Married) {
+ Married married = (Married) state;
+ fxAccount.setState(married.makeCohabitingState());
+ }
+ } finally {
+ fxAccount.releaseSharedAccountStateLock();
+ }
+ callback.handleError(null, e);
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ Logger.error(LOG_TAG, "Failed to get token.", e);
+ fxAccount.releaseSharedAccountStateLock();
+ callback.handleError(null, e);
+ }
+
+ @Override
+ public void handleBackoff(int backoffSeconds) {
+ // This is the token server telling us to back off.
+ Logger.info(LOG_TAG, "Token server requesting backoff of " + backoffSeconds + "s. Backoff handler: " + tokenBackoffHandler);
+ didReceiveBackoff = true;
+
+ // If we've already stored a backoff, overrule it: we only use the server
+ // value for token server scheduling.
+ tokenBackoffHandler.setEarliestNextRequest(delay(backoffSeconds * 1000));
+ }
+
+ private long delay(long delay) {
+ return System.currentTimeMillis() + delay;
+ }
+ };
+
+ TokenServerClient tokenServerclient = new TokenServerClient(tokenServerEndpointURI, executor);
+ tokenServerclient.getTokenFromBrowserIDAssertion(assertion, true, clientState, delegate);
+ }
+
+ /**
+ * A trivial Sync implementation that does not cache client keys,
+ * certificates, or tokens.
+ *
+ * This should be replaced with a full {@link FxAccountAuthenticator}-based
+ * token implementation.
+ */
+ @Override
+ public void onPerformSync(final Account account, final Bundle extras, final String authority, ContentProviderClient provider, final SyncResult syncResult) {
+ Logger.setThreadLogTag(FxAccountConstants.GLOBAL_LOG_TAG);
+ Logger.resetLogging();
+
+ final Context context = getContext();
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+
+ Logger.info(LOG_TAG, "Syncing FxAccount" +
+ " account named like " + Utils.obfuscateEmail(account.name) +
+ " for authority " + authority +
+ " with instance " + this + ".");
+
+ Logger.info(LOG_TAG, "Account last synced at: " + fxAccount.getLastSyncedTimestamp());
+
+ if (FxAccountUtils.LOG_PERSONAL_INFORMATION) {
+ fxAccount.dump();
+ }
+
+ FirefoxAccounts.logSyncOptions(extras);
+
+ if (this.lastSyncRealtimeMillis > 0L &&
+ (this.lastSyncRealtimeMillis + MINIMUM_SYNC_DELAY_MILLIS) > SystemClock.elapsedRealtime() &&
+ !extras.getBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, false)) {
+ Logger.info(LOG_TAG, "Not syncing FxAccount " + Utils.obfuscateEmail(account.name) +
+ ": minimum interval not met.");
+ TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_FAILED_BACKOFF, 1);
+ return;
+ }
+
+ // Pickle in a background thread to avoid strict mode warnings.
+ ThreadPool.run(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ AccountPickler.pickle(fxAccount, FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
+ } catch (Exception e) {
+ // Should never happen, but we really don't want to die in a background thread.
+ Logger.warn(LOG_TAG, "Got exception pickling current account details; ignoring.", e);
+ }
+ }
+ });
+
+ final BlockingQueue<Result> latch = new LinkedBlockingQueue<>(1);
+
+ Collection<String> knownStageNames = SyncConfiguration.validEngineNames();
+ Collection<String> stageNamesToSync = Utils.getStagesToSyncFromBundle(knownStageNames, extras);
+
+ final SyncDelegate syncDelegate = new SyncDelegate(latch, syncResult, fxAccount, stageNamesToSync);
+
+ try {
+ // This will be the same chunk of SharedPreferences that we pass through to GlobalSession/SyncConfiguration.
+ final SharedPreferences sharedPrefs = fxAccount.getSyncPrefs();
+
+ final BackoffHandler backgroundBackoffHandler = new PrefsBackoffHandler(sharedPrefs, "background");
+ final BackoffHandler rateLimitBackoffHandler = new PrefsBackoffHandler(sharedPrefs, "rate");
+
+ // If this sync was triggered by user action, this will be true.
+ final boolean isImmediate = (extras != null) &&
+ (extras.getBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, false) ||
+ extras.getBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, false));
+
+ // If it's not an immediate sync, it must be either periodic or tickled.
+ // Check our background rate limiter.
+ if (!isImmediate) {
+ if (!shouldPerformSync(backgroundBackoffHandler, "background", extras)) {
+ syncDelegate.rejectSync();
+ return;
+ }
+ }
+
+ // Regardless, let's make sure we're not syncing too often.
+ if (!shouldPerformSync(rateLimitBackoffHandler, "rate", extras)) {
+ syncDelegate.postponeSync(rateLimitBackoffHandler.delayMilliseconds());
+ return;
+ }
+
+ final SchedulePolicy schedulePolicy = new FxAccountSchedulePolicy(context, fxAccount);
+
+ // Set a small scheduled 'backoff' to rate-limit the next sync,
+ // and extend the background delay even further into the future.
+ schedulePolicy.configureBackoffMillisBeforeSyncing(rateLimitBackoffHandler, backgroundBackoffHandler);
+
+ final String tokenServerEndpoint = fxAccount.getTokenServerURI();
+ final URI tokenServerEndpointURI = new URI(tokenServerEndpoint);
+ final String audience = FxAccountUtils.getAudienceForURL(tokenServerEndpoint);
+
+ try {
+ // The clock starts... now!
+ fxAccount.acquireSharedAccountStateLock(FxAccountSyncAdapter.LOG_TAG);
+ } catch (InterruptedException e) {
+ // OK, skip this sync.
+ syncDelegate.handleError(e);
+ return;
+ }
+
+ final State state;
+ try {
+ state = fxAccount.getState();
+ } catch (Exception e) {
+ fxAccount.releaseSharedAccountStateLock();
+ syncDelegate.handleError(e);
+ return;
+ }
+
+ TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_STARTED, 1);
+
+ final FxAccountLoginStateMachine stateMachine = new FxAccountLoginStateMachine();
+ stateMachine.advance(state, StateLabel.Married, new FxADefaultLoginStateMachineDelegate(context, fxAccount) {
+ @Override
+ public void handleNotMarried(State notMarried) {
+ Logger.info(LOG_TAG, "handleNotMarried: in " + notMarried.getStateLabel());
+ schedulePolicy.onHandleFinal(notMarried.getNeededAction());
+ syncDelegate.handleCannotSync(notMarried);
+ }
+
+ private boolean shouldRequestToken(final BackoffHandler tokenBackoffHandler, final Bundle extras) {
+ return shouldPerformSync(tokenBackoffHandler, "token", extras);
+ }
+
+ @Override
+ public void handleMarried(Married married) {
+ schedulePolicy.onHandleFinal(married.getNeededAction());
+ Logger.info(LOG_TAG, "handleMarried: in " + married.getStateLabel());
+
+ try {
+ final String assertion = married.generateAssertion(audience, JSONWebTokenUtils.DEFAULT_ASSERTION_ISSUER);
+
+ /*
+ * At this point we're in the correct state to sync, and we're ready to fetch
+ * a token and do some work.
+ *
+ * But first we need to do two things:
+ * 1. Check to see whether we're in a backoff situation for the token server.
+ * If we are, but we're not forcing a sync, then we go no further.
+ * 2. Clear an existing backoff (if we're syncing it doesn't matter, and if
+ * we're forcing we'll get a new backoff if things are still bad).
+ *
+ * Note that we don't check the storage backoff before the token dance: the token
+ * server tells us which server we're syncing to!
+ *
+ * That logic lives in the TokenServerClientDelegate elsewhere in this file.
+ */
+
+ // Strictly speaking this backoff check could be done prior to walking through
+ // the login state machine, allowing us to short-circuit sooner.
+ // We don't expect many token server backoffs, and most users will be sitting
+ // in the Married state, so instead we simply do this here, once.
+ final BackoffHandler tokenBackoffHandler = new PrefsBackoffHandler(sharedPrefs, "token");
+ if (!shouldRequestToken(tokenBackoffHandler, extras)) {
+ Logger.info(LOG_TAG, "Not syncing (token server).");
+ syncDelegate.postponeSync(tokenBackoffHandler.delayMilliseconds());
+ return;
+ }
+
+ final SessionCallback sessionCallback = new SessionCallback(syncDelegate, schedulePolicy);
+ final KeyBundle syncKeyBundle = married.getSyncKeyBundle();
+ final String clientState = married.getClientState();
+ syncWithAssertion(audience, assertion, tokenServerEndpointURI, tokenBackoffHandler, sharedPrefs, syncKeyBundle, clientState, sessionCallback, extras, fxAccount);
+
+ // Register the device if necessary (asynchronous, in another thread)
+ if (fxAccount.getDeviceRegistrationVersion() != FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION
+ || TextUtils.isEmpty(fxAccount.getDeviceId())) {
+ FxAccountDeviceRegistrator.register(context);
+ }
+
+ // Force fetch the profile avatar information. (asynchronous, in another thread)
+ Logger.info(LOG_TAG, "Fetching profile avatar information.");
+ fxAccount.fetchProfileJSON();
+ } catch (Exception e) {
+ syncDelegate.handleError(e);
+ return;
+ }
+ }
+ });
+
+ latch.take();
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Got error syncing.", e);
+ syncDelegate.handleError(e);
+ } finally {
+ fxAccount.releaseSharedAccountStateLock();
+ }
+
+ Logger.info(LOG_TAG, "Syncing done.");
+ lastSyncRealtimeMillis = SystemClock.elapsedRealtime();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncDelegate.java
new file mode 100644
index 000000000..71148f66c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncDelegate.java
@@ -0,0 +1,110 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.sync;
+
+import java.util.concurrent.BlockingQueue;
+
+import org.mozilla.gecko.fxa.login.State;
+
+import android.content.SyncResult;
+
+public class FxAccountSyncDelegate {
+ public enum Result {
+ Success,
+ Error,
+ Postponed,
+ Rejected,
+ }
+
+ protected final BlockingQueue<Result> latch;
+ protected final SyncResult syncResult;
+
+ public FxAccountSyncDelegate(BlockingQueue<Result> latch, SyncResult syncResult) {
+ if (latch == null) {
+ throw new IllegalArgumentException("latch must not be null");
+ }
+ if (syncResult == null) {
+ throw new IllegalArgumentException("syncResult must not be null");
+ }
+ this.latch = latch;
+ this.syncResult = syncResult;
+ }
+
+ /**
+ * No error! Say that we made progress.
+ */
+ protected void setSyncResultSuccess() {
+ syncResult.stats.numUpdates += 1;
+ }
+
+ /**
+ * Soft error. Say that we made progress, so that Android will sync us again
+ * after exponential backoff.
+ */
+ protected void setSyncResultSoftError() {
+ syncResult.stats.numUpdates += 1;
+ syncResult.stats.numIoExceptions += 1;
+ }
+
+ /**
+ * Hard error. We don't want Android to sync us again, even if we make
+ * progress, until the user intervenes.
+ */
+ protected void setSyncResultHardError() {
+ syncResult.stats.numAuthExceptions += 1;
+ }
+
+ public void handleSuccess() {
+ setSyncResultSuccess();
+ latch.offer(Result.Success);
+ }
+
+ public void handleError(Exception e) {
+ setSyncResultSoftError();
+ latch.offer(Result.Error);
+ }
+
+ /**
+ * When the login machine terminates, we might not be in the
+ * <code>Married</code> state, and therefore we can't sync. This method
+ * messages as much to the user.
+ * <p>
+ * To avoid stopping us syncing altogether, we set a soft error rather than
+ * a hard error. In future, we would like to set a hard error if we are in,
+ * for example, the <code>Separated</code> state, and then have some user
+ * initiated activity mark the Android account as ready to sync again. This
+ * is tricky, though, so we play it safe for now.
+ *
+ * @param finalState
+ * that login machine ended in.
+ */
+ public void handleCannotSync(State finalState) {
+ setSyncResultSoftError();
+ latch.offer(Result.Error);
+ }
+
+ public void postponeSync(long millis) {
+ if (millis > 0) {
+ // delayUntil is broken: https://code.google.com/p/android/issues/detail?id=65669
+ // So we don't bother doing this. Instead, we rely on the periodic sync
+ // we schedule, and the backoff handler for the rest.
+ /*
+ Logger.warn(LOG_TAG, "Postponing sync by " + millis + "ms.");
+ syncResult.delayUntil = millis / 1000;
+ */
+ }
+ setSyncResultSoftError();
+ latch.offer(Result.Postponed);
+ }
+
+ /**
+ * Simply don't sync, without setting any error flags.
+ * This is the appropriate behavior when a routine backoff has not yet
+ * been met.
+ */
+ public void rejectSync() {
+ latch.offer(Result.Rejected);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncService.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncService.java
new file mode 100644
index 000000000..59c06ca97
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncService.java
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.sync;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.IBinder;
+
+public class FxAccountSyncService extends Service {
+ private static final Object syncAdapterLock = new Object();
+ private static FxAccountSyncAdapter syncAdapter;
+
+ @Override
+ public void onCreate() {
+ synchronized (syncAdapterLock) {
+ if (syncAdapter == null) {
+ syncAdapter = new FxAccountSyncAdapter(getApplicationContext(), true);
+ }
+ }
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return syncAdapter.getSyncAdapterBinder();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncStatusHelper.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncStatusHelper.java
new file mode 100644
index 000000000..ca64d4f87
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncStatusHelper.java
@@ -0,0 +1,113 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.sync;
+
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.WeakHashMap;
+
+import org.mozilla.gecko.fxa.SyncStatusListener;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.ContentResolver;
+import android.content.SyncStatusObserver;
+
+/**
+ * Abstract away some details of Android's SyncStatusObserver.
+ * <p>
+ * Provides a simplified sync started/sync finished delegate.
+ */
+public class FxAccountSyncStatusHelper implements SyncStatusObserver {
+ @SuppressWarnings("unused")
+ private static final String LOG_TAG = FxAccountSyncStatusHelper.class.getSimpleName();
+
+ protected static FxAccountSyncStatusHelper sInstance;
+
+ public synchronized static FxAccountSyncStatusHelper getInstance() {
+ if (sInstance == null) {
+ sInstance = new FxAccountSyncStatusHelper();
+ }
+ return sInstance;
+ }
+
+ // Used to unregister this as a listener.
+ protected Object handle;
+
+ // Maps delegates to whether their underlying Android account was syncing the
+ // last time we observed a status change.
+ protected Map<SyncStatusListener, Boolean> delegates = new WeakHashMap<SyncStatusListener, Boolean>();
+
+ @Override
+ public synchronized void onStatusChanged(int which) {
+ for (Entry<SyncStatusListener, Boolean> entry : delegates.entrySet()) {
+ final SyncStatusListener delegate = entry.getKey();
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(delegate.getContext(), delegate.getAccount());
+ final boolean active = fxAccount.isCurrentlySyncing();
+ // Remember for later.
+ boolean wasActiveLastTime = entry.getValue();
+ // It's okay to update the value of an entry while iterating the entrySet.
+ entry.setValue(active);
+
+ if (active && !wasActiveLastTime) {
+ // We've started a sync.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ delegate.onSyncStarted();
+ }
+ });
+ }
+
+ if (!active && wasActiveLastTime) {
+ // We've finished a sync.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ delegate.onSyncFinished();
+ }
+ });
+ }
+ }
+ }
+
+ protected void addListener() {
+ final int mask = ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE;
+ if (this.handle != null) {
+ throw new IllegalStateException("Already registered this as an observer?");
+ }
+ this.handle = ContentResolver.addStatusChangeListener(mask, this);
+ }
+
+ protected void removeListener() {
+ Object handle = this.handle;
+ this.handle = null;
+ if (handle != null) {
+ ContentResolver.removeStatusChangeListener(handle);
+ }
+ }
+
+ public synchronized void startObserving(SyncStatusListener delegate) {
+ if (delegate == null) {
+ throw new IllegalArgumentException("delegate must not be null");
+ }
+ if (delegates.containsKey(delegate)) {
+ return;
+ }
+ // If we are the first delegate to the party, start listening.
+ if (delegates.isEmpty()) {
+ addListener();
+ }
+ delegates.put(delegate, Boolean.FALSE);
+ }
+
+ public synchronized void stopObserving(SyncStatusListener delegate) {
+ delegates.remove(delegate);
+ // If we are the last delegate leaving the party, stop listening.
+ if (delegates.isEmpty()) {
+ removeListener();
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/SchedulePolicy.java b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/SchedulePolicy.java
new file mode 100644
index 000000000..809191f5e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/SchedulePolicy.java
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.sync;
+
+import org.mozilla.gecko.fxa.login.State.Action;
+import org.mozilla.gecko.sync.BackoffHandler;
+
+public interface SchedulePolicy {
+ /**
+ * Call this with the number of other clients syncing to the account.
+ */
+ public abstract void onSuccessfulSync(int otherClientsCount);
+ public abstract void onHandleFinal(Action needed);
+ public abstract void onUpgradeRequired();
+ public abstract void onUnauthorized();
+
+ /**
+ * Before a sync we typically wish to adjust our backoff policy. This cleans
+ * the slate prior to encountering a new backoff, and also functions as a rate
+ * limiter.
+ *
+ * The {@link SchedulePolicy} acts as a controller for the {@link BackoffHandler}.
+ * As a result of calling these two methods, the {@link BackoffHandler} will be
+ * mutated, and additional side-effects (such as scheduling periodic syncs) can
+ * occur.
+ *
+ * @param rateHandler the backoff handler to configure for basic rate limiting.
+ * @param backgroundHandler the backoff handler to configure for background operations.
+ */
+ public abstract void configureBackoffMillisBeforeSyncing(BackoffHandler rateHandler, BackoffHandler backgroundHandler);
+
+ /**
+ * We received an explicit backoff instruction, typically from a server.
+ *
+ * @param onlyExtend
+ * if <code>true</code>, the backoff handler will be asked to update
+ * its backoff only if the provided value is greater than the current
+ * backoff.
+ */
+ public abstract void configureBackoffMillisOnBackoff(BackoffHandler backoffHandler, long backoffMillis, boolean onlyExtend);
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/push/RegisterUserAgentResponse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/push/RegisterUserAgentResponse.java
new file mode 100644
index 000000000..3bbb7e8b4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/RegisterUserAgentResponse.java
@@ -0,0 +1,19 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push;
+
+/**
+ * Thin container for a register User-Agent response.
+ */
+public class RegisterUserAgentResponse {
+ public final String uaid;
+ public final String secret;
+
+ public RegisterUserAgentResponse(String uaid, String secret) {
+ this.uaid = uaid;
+ this.secret = secret;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/push/SubscribeChannelResponse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/push/SubscribeChannelResponse.java
new file mode 100644
index 000000000..009a7f838
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/SubscribeChannelResponse.java
@@ -0,0 +1,19 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push;
+
+/**
+ * Thin container for a subscribe channel response.
+ */
+public class SubscribeChannelResponse {
+ public final String channelID;
+ public final String endpoint;
+
+ public SubscribeChannelResponse(String channelID, String endpoint) {
+ this.channelID = channelID;
+ this.endpoint = endpoint;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java
new file mode 100644
index 000000000..8edd92f9e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java
@@ -0,0 +1,410 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push.autopush;
+
+import android.text.TextUtils;
+
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.push.RegisterUserAgentResponse;
+import org.mozilla.gecko.push.SubscribeChannelResponse;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BaseResourceDelegate;
+import org.mozilla.gecko.sync.net.BearerAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.Resource;
+import org.mozilla.gecko.sync.net.SyncResponse;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.util.Locale;
+import java.util.concurrent.Executor;
+
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.HttpHeaders;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+
+
+/**
+ * Interact with the autopush endpoint HTTP API.
+ * <p/>
+ * The API is a Mozilla-proprietary interface, and not even specified to Mozilla's usual ad-hoc standards.
+ * This client is written against a work-in-progress, un-deployed upstream commit.
+ */
+public class AutopushClient {
+ protected static final String LOG_TAG = AutopushClient.class.getSimpleName();
+
+ protected static final String ACCEPT_HEADER = "application/json;charset=utf-8";
+ protected static final String TYPE = "gcm";
+
+ protected static final String JSON_KEY_UAID = "uaid";
+ protected static final String JSON_KEY_SECRET = "secret";
+ protected static final String JSON_KEY_CHANNEL_ID = "channelID";
+ protected static final String JSON_KEY_ENDPOINT = "endpoint";
+
+ protected static final String[] REGISTER_USER_AGENT_RESPONSE_REQUIRED_STRING_FIELDS = new String[] { JSON_KEY_UAID, JSON_KEY_SECRET, JSON_KEY_CHANNEL_ID, JSON_KEY_ENDPOINT };
+ protected static final String[] REGISTER_CHANNEL_RESPONSE_REQUIRED_STRING_FIELDS = new String[] { JSON_KEY_CHANNEL_ID, JSON_KEY_ENDPOINT };
+
+ public static final String JSON_KEY_CODE = "code";
+ public static final String JSON_KEY_ERRNO = "errno";
+ public static final String JSON_KEY_ERROR = "error";
+ public static final String JSON_KEY_MESSAGE = "message";
+
+ protected static final String[] requiredErrorStringFields = { JSON_KEY_ERROR, JSON_KEY_MESSAGE };
+ protected static final String[] requiredErrorLongFields = { JSON_KEY_CODE, JSON_KEY_ERRNO };
+
+ /**
+ * The server's URI.
+ * <p>
+ * We assume throughout that this ends with a trailing slash (and guarantee as
+ * much in the constructor).
+ */
+ public final String serverURI;
+
+ protected final Executor executor;
+
+ public AutopushClient(String serverURI, Executor executor) {
+ if (serverURI == null) {
+ throw new IllegalArgumentException("Must provide a server URI.");
+ }
+ if (executor == null) {
+ throw new IllegalArgumentException("Must provide a non-null executor.");
+ }
+ this.serverURI = serverURI.endsWith("/") ? serverURI : serverURI + "/";
+ if (!this.serverURI.endsWith("/")) {
+ throw new IllegalArgumentException("Constructed serverURI must end with a trailing slash: " + this.serverURI);
+ }
+ this.executor = executor;
+ }
+
+ /**
+ * A legal autopush server URL includes a sender ID embedded into it. Extract it.
+ *
+ * @return a non-null non-empty sender ID.
+ * @throws AutopushClientException on failure.
+ */
+ public String getSenderIDFromServerURI() throws AutopushClientException {
+ // Turn "https://updates-autopush-dev.stage.mozaws.net/v1/gcm/829133274407/" into "829133274407".
+ final String[] parts = serverURI.split("/", -1); // The -1 keeps the trailing empty part.
+ if (parts.length < 3) {
+ throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI);
+ }
+ if (!TextUtils.isEmpty(parts[parts.length - 1])) {
+ // We guarantee a trailing slash, so we should always have an empty part at the tail.
+ throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI);
+ }
+ if (!TextUtils.equals("gcm", parts[parts.length - 3])) {
+ // We should always have /gcm/senderID/.
+ throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI);
+ }
+ final String senderID = parts[parts.length - 2];
+ if (TextUtils.isEmpty(senderID)) {
+ // Something is horribly wrong -- we have /gcm//. Abort.
+ throw new AutopushClientException("Could not get sender ID from autopush server URI: " + serverURI);
+ }
+ return senderID;
+ }
+
+ /**
+ * Process a typed value extracted from a successful response (in an
+ * endpoint-dependent way).
+ */
+ public interface RequestDelegate<T> {
+ void handleError(Exception e);
+ void handleFailure(AutopushClientException e);
+ void handleSuccess(T result);
+ }
+
+ /**
+ * Intepret a response from the autopush server.
+ * <p>
+ * Throw an appropriate exception on errors; otherwise, return the response's
+ * status code.
+ *
+ * @return response's HTTP status code.
+ * @throws AutopushClientException
+ */
+ public static int validateResponse(HttpResponse response) throws AutopushClientException {
+ final int status = response.getStatusLine().getStatusCode();
+ if (200 <= status && status <= 299) {
+ return status;
+ }
+ long code;
+ long errno;
+ String error;
+ String message;
+ String info;
+ ExtendedJSONObject body;
+ try {
+ body = new SyncStorageResponse(response).jsonObjectBody();
+ // TODO: The service doesn't do the right thing yet :(
+ // body.throwIfFieldsMissingOrMisTyped(requiredErrorStringFields, String.class);
+ body.throwIfFieldsMissingOrMisTyped(requiredErrorLongFields, Long.class);
+ // Would throw above if missing; the -1 defaults quiet NPE warnings.
+ code = body.getLong(JSON_KEY_CODE, -1);
+ errno = body.getLong(JSON_KEY_ERRNO, -1);
+ error = body.getString(JSON_KEY_ERROR);
+ message = body.getString(JSON_KEY_MESSAGE);
+ } catch (Exception e) {
+ throw new AutopushClientException.AutopushClientMalformedResponseException(response);
+ }
+ throw new AutopushClientException.AutopushClientRemoteException(response, code, errno, error, message, body);
+ }
+
+ protected <T> void invokeHandleError(final RequestDelegate<T> delegate, final Exception e) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.handleError(e);
+ }
+ });
+ }
+
+ protected <T> void post(BaseResource resource, final ExtendedJSONObject requestBody, final RequestDelegate<T> delegate) {
+ try {
+ if (requestBody == null) {
+ resource.post((HttpEntity) null);
+ } else {
+ resource.post(requestBody);
+ }
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+ }
+
+ /**
+ * Translate resource callbacks into request callbacks invoked on the provided
+ * executor.
+ * <p>
+ * Override <code>handleSuccess</code> to parse the body of the resource
+ * request and call the request callback. <code>handleSuccess</code> is
+ * invoked via the executor, so you don't need to delegate further.
+ */
+ protected abstract class ResourceDelegate<T> extends BaseResourceDelegate {
+ protected abstract void handleSuccess(final int status, HttpResponse response, final ExtendedJSONObject body);
+
+ protected final String secret;
+ protected final RequestDelegate<T> delegate;
+
+ /**
+ * Create a delegate for an un-authenticated resource.
+ */
+ public ResourceDelegate(final Resource resource, final String secret, final RequestDelegate<T> delegate) {
+ super(resource);
+ this.delegate = delegate;
+ this.secret = secret;
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ if (secret != null) {
+ return new BearerAuthHeaderProvider(secret);
+ }
+ return null;
+ }
+
+ @Override
+ public String getUserAgent() {
+ return FxAccountConstants.USER_AGENT;
+ }
+
+ @Override
+ public void handleHttpResponse(HttpResponse response) {
+ try {
+ final int status = validateResponse(response);
+ invokeHandleSuccess(status, response);
+ } catch (AutopushClientException e) {
+ invokeHandleFailure(e);
+ }
+ }
+
+ protected void invokeHandleFailure(final AutopushClientException e) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.handleFailure(e);
+ }
+ });
+ }
+
+ protected void invokeHandleSuccess(final int status, final HttpResponse response) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ ExtendedJSONObject body = new SyncResponse(response).jsonObjectBody();
+ ResourceDelegate.this.handleSuccess(status, response, body);
+ } catch (Exception e) {
+ delegate.handleError(e);
+ }
+ }
+ });
+ }
+
+ @Override
+ public void handleHttpProtocolException(final ClientProtocolException e) {
+ invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public void handleHttpIOException(IOException e) {
+ invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public void handleTransportException(GeneralSecurityException e) {
+ invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+ super.addHeaders(request, client);
+
+ // The basics.
+ final Locale locale = Locale.getDefault();
+ request.addHeader(HttpHeaders.ACCEPT_LANGUAGE, Locales.getLanguageTag(locale));
+ request.addHeader(HttpHeaders.ACCEPT, ACCEPT_HEADER);
+ }
+ }
+
+ public void registerUserAgent(final String token, RequestDelegate<RegisterUserAgentResponse> delegate) {
+ BaseResource resource;
+ try {
+ resource = new BaseResource(new URI(serverURI + "registration"));
+ } catch (URISyntaxException e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<RegisterUserAgentResponse>(resource, null, delegate) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+ try {
+ body.throwIfFieldsMissingOrMisTyped(REGISTER_USER_AGENT_RESPONSE_REQUIRED_STRING_FIELDS, String.class);
+ final String uaid = body.getString(JSON_KEY_UAID);
+ final String secret = body.getString(JSON_KEY_SECRET);
+ delegate.handleSuccess(new RegisterUserAgentResponse(uaid, secret));
+ return;
+ } catch (Exception e) {
+ delegate.handleError(e);
+ return;
+ }
+ }
+ };
+
+ final ExtendedJSONObject body = new ExtendedJSONObject();
+ body.put("type", TYPE);
+ body.put("token", token);
+
+ resource.post(body);
+ }
+
+ public void reregisterUserAgent(final String uaid, final String secret, final String token, RequestDelegate<Void> delegate) {
+ final BaseResource resource;
+ try {
+ resource = new BaseResource(new URI(serverURI + "registration/" + uaid));
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<Void>(resource, secret, delegate) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+ try {
+ delegate.handleSuccess(null);
+ return;
+ } catch (Exception e) {
+ delegate.handleError(e);
+ return;
+ }
+ }
+ };
+
+ final ExtendedJSONObject body = new ExtendedJSONObject();
+ body.put("type", TYPE);
+ body.put("token", token);
+
+ resource.put(body);
+ }
+
+
+ public void subscribeChannel(final String uaid, final String secret, final String appServerKey, RequestDelegate<SubscribeChannelResponse> delegate) {
+ final BaseResource resource;
+ try {
+ resource = new BaseResource(new URI(serverURI + "registration/" + uaid + "/subscription"));
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<SubscribeChannelResponse>(resource, secret, delegate) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+ try {
+ body.throwIfFieldsMissingOrMisTyped(REGISTER_CHANNEL_RESPONSE_REQUIRED_STRING_FIELDS, String.class);
+ final String channelID = body.getString(JSON_KEY_CHANNEL_ID);
+ final String endpoint = body.getString(JSON_KEY_ENDPOINT);
+ delegate.handleSuccess(new SubscribeChannelResponse(channelID, endpoint));
+ return;
+ } catch (Exception e) {
+ delegate.handleError(e);
+ return;
+ }
+ }
+ };
+
+ final ExtendedJSONObject body = new ExtendedJSONObject();
+ body.put("key", appServerKey);
+ resource.post(body);
+ }
+
+ public void unsubscribeChannel(final String uaid, final String secret, final String channelID, RequestDelegate<Void> delegate) {
+ final BaseResource resource;
+ try {
+ resource = new BaseResource(new URI(serverURI + "registration/" + uaid + "/subscription/" + channelID));
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<Void>(resource, secret, delegate) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+ delegate.handleSuccess(null);
+ }
+ };
+
+ resource.delete();
+ }
+
+ public void unregisterUserAgent(final String uaid, final String secret, RequestDelegate<Void> delegate) {
+ final BaseResource resource;
+ try {
+ resource = new BaseResource(new URI(serverURI + "registration/" + uaid));
+ } catch (Exception e) {
+ invokeHandleError(delegate, e);
+ return;
+ }
+
+ resource.delegate = new ResourceDelegate<Void>(resource, secret, delegate) {
+ @Override
+ public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+ delegate.handleSuccess(null);
+ }
+ };
+
+ resource.delete();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java
new file mode 100644
index 000000000..e3fda7a45
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push.autopush;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.HttpStatus;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+public class AutopushClientException extends Exception {
+ private static final long serialVersionUID = 7953459541558266500L;
+
+ public AutopushClientException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public AutopushClientException(Exception e) {
+ super(e);
+ }
+
+ public boolean isTransientError() {
+ return false;
+ }
+
+ public static class AutopushClientRemoteException extends AutopushClientException {
+ private static final long serialVersionUID = 2209313149952001000L;
+
+ public final HttpResponse response;
+ public final long httpStatusCode;
+ public final long apiErrorNumber;
+ public final String error;
+ public final String message;
+ public final ExtendedJSONObject body;
+
+ public AutopushClientRemoteException(HttpResponse response, long httpStatusCode, long apiErrorNumber, String error, String message, ExtendedJSONObject body) {
+ super(new HTTPFailureException(new SyncStorageResponse(response)));
+ if (body == null) {
+ throw new IllegalArgumentException("body must not be null");
+ }
+ this.response = response;
+ this.httpStatusCode = httpStatusCode;
+ this.apiErrorNumber = apiErrorNumber;
+ this.error = error;
+ this.message = message;
+ this.body = body;
+ }
+
+ @Override
+ public String toString() {
+ return "<AutopushClientRemoteException " + this.httpStatusCode + " [" + this.apiErrorNumber + "]: " + this.message + ">";
+ }
+
+ public boolean isInvalidAuthentication() {
+ return httpStatusCode == HttpStatus.SC_UNAUTHORIZED;
+ }
+
+ public boolean isNotFound() {
+ return httpStatusCode == HttpStatus.SC_NOT_FOUND;
+ }
+
+ public boolean isGone() {
+ return httpStatusCode == HttpStatus.SC_GONE;
+ }
+
+ @Override
+ public boolean isTransientError() {
+ return httpStatusCode >= 500;
+ }
+ }
+
+ public static class AutopushClientMalformedResponseException extends AutopushClientRemoteException {
+ private static final long serialVersionUID = 2209313149952001909L;
+
+ public AutopushClientMalformedResponseException(HttpResponse response) {
+ super(response, 0, 999, "Response malformed", "Response malformed", new ExtendedJSONObject());
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/AlreadySyncingException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/AlreadySyncingException.java
new file mode 100644
index 000000000..75eb5ad37
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/AlreadySyncingException.java
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+
+import android.content.SyncResult;
+
+public class AlreadySyncingException extends SyncException {
+ Stage inState;
+ public AlreadySyncingException(Stage currentState) {
+ inState = currentState;
+ }
+
+ private static final long serialVersionUID = -5647548462539009893L;
+
+ @Override
+ public void updateStats(GlobalSession globalSession, SyncResult syncResult) {
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BackoffHandler.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BackoffHandler.java
new file mode 100644
index 000000000..abb880621
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BackoffHandler.java
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+
+public interface BackoffHandler {
+ public long getEarliestNextRequest();
+
+ /**
+ * Provide a timestamp in millis before which we shouldn't sync again.
+ * Overrides any existing value.
+ *
+ * @param next
+ * a timestamp in milliseconds.
+ */
+ public void setEarliestNextRequest(long next);
+
+ /**
+ * Provide a timestamp in millis before which we shouldn't sync again. Only
+ * change our persisted value if it's later than the existing time.
+ *
+ * @param next
+ * a timestamp in milliseconds.
+ */
+ public void extendEarliestNextRequest(long next);
+
+ /**
+ * Return the number of milliseconds until we're allowed to sync again,
+ * or 0 if now is fine.
+ */
+ public long delayMilliseconds();
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BadRequiredFieldJSONException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BadRequiredFieldJSONException.java
new file mode 100644
index 000000000..3db93652d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/BadRequiredFieldJSONException.java
@@ -0,0 +1,5 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CollectionKeys.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CollectionKeys.java
new file mode 100644
index 000000000..1fd363bcb
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CollectionKeys.java
@@ -0,0 +1,199 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import org.json.simple.JSONArray;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+
+public class CollectionKeys {
+ private KeyBundle defaultKeyBundle = null;
+ private final HashMap<String, KeyBundle> collectionKeyBundles = new HashMap<String, KeyBundle>();
+
+ /**
+ * Randomly generate a basic CollectionKeys object.
+ * @throws CryptoException
+ */
+ public static CollectionKeys generateCollectionKeys() throws CryptoException {
+ CollectionKeys ck = new CollectionKeys();
+ ck.clear();
+ ck.defaultKeyBundle = KeyBundle.withRandomKeys();
+ // TODO: eventually we would like to keep per-collection keys, just generate
+ // new ones as appropriate.
+ return ck;
+ }
+
+ public KeyBundle defaultKeyBundle() throws NoCollectionKeysSetException {
+ if (this.defaultKeyBundle == null) {
+ throw new NoCollectionKeysSetException();
+ }
+ return this.defaultKeyBundle;
+ }
+
+ public boolean keyBundleForCollectionIsNotDefault(String collection) {
+ return collectionKeyBundles.containsKey(collection);
+ }
+
+ public KeyBundle keyBundleForCollection(String collection)
+ throws NoCollectionKeysSetException {
+ if (this.defaultKeyBundle == null) {
+ throw new NoCollectionKeysSetException();
+ }
+ if (keyBundleForCollectionIsNotDefault(collection)) {
+ return collectionKeyBundles.get(collection);
+ }
+ return this.defaultKeyBundle;
+ }
+
+ /**
+ * Take a pair of values in a JSON array, handing them off to KeyBundle to
+ * produce a usable keypair.
+ */
+ private static KeyBundle arrayToKeyBundle(JSONArray array) throws UnsupportedEncodingException {
+ String encKeyStr = (String) array.get(0);
+ String hmacKeyStr = (String) array.get(1);
+ return KeyBundle.fromBase64EncodedKeys(encKeyStr, hmacKeyStr);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static JSONArray keyBundleToArray(KeyBundle bundle) {
+ // Generate JSON.
+ JSONArray keysArray = new JSONArray();
+ keysArray.add(new String(Base64.encodeBase64(bundle.getEncryptionKey())));
+ keysArray.add(new String(Base64.encodeBase64(bundle.getHMACKey())));
+ return keysArray;
+ }
+
+ private ExtendedJSONObject asRecordContents() throws NoCollectionKeysSetException {
+ ExtendedJSONObject json = new ExtendedJSONObject();
+ json.put("id", "keys");
+ json.put("collection", "crypto");
+ json.put("default", keyBundleToArray(this.defaultKeyBundle()));
+ ExtendedJSONObject colls = new ExtendedJSONObject();
+ for (Entry<String, KeyBundle> collKey : collectionKeyBundles.entrySet()) {
+ colls.put(collKey.getKey(), keyBundleToArray(collKey.getValue()));
+ }
+ json.put("collections", colls);
+ return json;
+ }
+
+ public CryptoRecord asCryptoRecord() throws NoCollectionKeysSetException {
+ ExtendedJSONObject payload = this.asRecordContents();
+ CryptoRecord record = new CryptoRecord(payload);
+ record.collection = "crypto";
+ record.guid = "keys";
+ record.deleted = false;
+ return record;
+ }
+
+ /**
+ * Set my key bundle and collection keys with the given key bundle and data
+ * (possibly decrypted) from the given record.
+ *
+ * @param keys
+ * A "crypto/keys" <code>CryptoRecord</code>, encrypted with
+ * <code>syncKeyBundle</code> if <code>syncKeyBundle</code> is non-null.
+ * @param syncKeyBundle
+ * If non-null, the sync key bundle to decrypt <code>keys</code> with.
+ */
+ public void setKeyPairsFromWBO(CryptoRecord keys, KeyBundle syncKeyBundle)
+ throws CryptoException, IOException, NonObjectJSONException {
+ if (keys == null) {
+ throw new IllegalArgumentException("cannot set key pairs from null record");
+ }
+ if (syncKeyBundle != null) {
+ keys.keyBundle = syncKeyBundle;
+ keys.decrypt();
+ }
+ ExtendedJSONObject cleartext = keys.payload;
+ KeyBundle defaultKey = arrayToKeyBundle((JSONArray) cleartext.get("default"));
+
+ ExtendedJSONObject collections = cleartext.getObject("collections");
+ HashMap<String, KeyBundle> collectionKeys = new HashMap<String, KeyBundle>();
+ for (Entry<String, Object> pair : collections.entrySet()) {
+ KeyBundle bundle = arrayToKeyBundle((JSONArray) pair.getValue());
+ collectionKeys.put(pair.getKey(), bundle);
+ }
+
+ this.collectionKeyBundles.clear();
+ this.collectionKeyBundles.putAll(collectionKeys);
+ this.defaultKeyBundle = defaultKey;
+ }
+
+ public void setKeyBundleForCollection(String collection, KeyBundle keys) {
+ this.collectionKeyBundles.put(collection, keys);
+ }
+
+ public void setDefaultKeyBundle(KeyBundle keys) {
+ this.defaultKeyBundle = keys;
+ }
+
+ public void clear() {
+ this.defaultKeyBundle = null;
+ this.collectionKeyBundles.clear();
+ }
+
+ /**
+ * Return set of collections where key is either missing from one collection
+ * or not the same in both collections.
+ * <p>
+ * Does not check for different default keys.
+ */
+ public static Set<String> differences(CollectionKeys a, CollectionKeys b) {
+ Set<String> differences = new HashSet<String>();
+ Set<String> collections = new HashSet<String>(a.collectionKeyBundles.keySet());
+ collections.addAll(b.collectionKeyBundles.keySet());
+
+ // Iterate through one collection, collecting missing and differences.
+ for (String collection : collections) {
+ KeyBundle keyA;
+ KeyBundle keyB;
+ try {
+ keyA = a.keyBundleForCollection(collection); // Will return default key as appropriate.
+ keyB = b.keyBundleForCollection(collection); // Will return default key as appropriate.
+ } catch (NoCollectionKeysSetException e) {
+ differences.add(collection);
+ continue;
+ }
+ // keyA and keyB are not null at this point.
+ if (!keyA.equals(keyB)) {
+ differences.add(collection);
+ }
+ }
+
+ return differences;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof CollectionKeys)) {
+ return false;
+ }
+ CollectionKeys other = (CollectionKeys) o;
+ try {
+ // It would be nice to use map equality here, but there can be map entries
+ // where the key is the default key that should compare equal to a missing
+ // map entry. Therefore, we always compute the set of differences.
+ return defaultKeyBundle().equals(other.defaultKeyBundle()) &&
+ CollectionKeys.differences(this, other).isEmpty();
+ } catch (NoCollectionKeysSetException e) {
+ // If either default key bundle is not set, we'll say the bundles are not equal.
+ return false;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java
new file mode 100644
index 000000000..371603de5
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java
@@ -0,0 +1,261 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Process commands received from Sync clients.
+ * <p>
+ * We need a command processor at two different times:
+ * <ol>
+ * <li>We execute commands during the "clients" engine stage of a Sync. Each
+ * command takes a <code>GlobalSession</code> instance as a parameter.</li>
+ * <li>We queue commands to be executed or propagated to other Sync clients
+ * during an activity completely unrelated to a sync</li>
+ * </ol>
+ * To provide a processor for both these time frames, we maintain a static
+ * long-lived singleton.
+ */
+public class CommandProcessor {
+ private static final String LOG_TAG = "Command";
+ private static final AtomicInteger currentId = new AtomicInteger();
+ protected ConcurrentHashMap<String, CommandRunner> commands = new ConcurrentHashMap<String, CommandRunner>();
+
+ private final static CommandProcessor processor = new CommandProcessor();
+
+ /**
+ * Get the global singleton command processor.
+ *
+ * @return the singleton processor.
+ */
+ public static CommandProcessor getProcessor() {
+ return processor;
+ }
+
+ public static class Command {
+ public final String commandType;
+ public final JSONArray args;
+ private List<String> argsList;
+
+ public Command(String commandType, JSONArray args) {
+ this.commandType = commandType;
+ this.args = args;
+ }
+
+ /**
+ * Get list of arguments as strings. Individual arguments may be null.
+ *
+ * @return list of strings.
+ */
+ public synchronized List<String> getArgsList() {
+ if (argsList == null) {
+ ArrayList<String> argsList = new ArrayList<String>(args.size());
+
+ for (int i = 0; i < args.size(); i++) {
+ final Object arg = args.get(i);
+ if (arg == null) {
+ argsList.add(null);
+ continue;
+ }
+ argsList.add(arg.toString());
+ }
+ this.argsList = argsList;
+ }
+ return this.argsList;
+ }
+
+ @SuppressWarnings("unchecked")
+ public JSONObject asJSONObject() {
+ JSONObject out = new JSONObject();
+ out.put("command", this.commandType);
+ out.put("args", this.args);
+ return out;
+ }
+ }
+
+ /**
+ * Register a command.
+ * <p>
+ * Any existing registration is overwritten.
+ *
+ * @param commandType
+ * the name of the command, i.e., "displayURI".
+ * @param command
+ * the <code>CommandRunner</code> instance that should handle the
+ * command.
+ */
+ public void registerCommand(String commandType, CommandRunner command) {
+ commands.put(commandType, command);
+ }
+
+ /**
+ * Process a command in the context of the given global session.
+ *
+ * @param session
+ * the <code>GlobalSession</code> instance currently executing.
+ * @param unparsedCommand
+ * command as a <code>ExtendedJSONObject</code> instance.
+ */
+ public void processCommand(final GlobalSession session, ExtendedJSONObject unparsedCommand) {
+ Command command = parseCommand(unparsedCommand);
+ if (command == null) {
+ Logger.debug(LOG_TAG, "Invalid command: " + unparsedCommand + " will not be processed.");
+ return;
+ }
+
+ CommandRunner executableCommand = commands.get(command.commandType);
+ if (executableCommand == null) {
+ Logger.debug(LOG_TAG, "Command \"" + command.commandType + "\" not registered and will not be processed.");
+ return;
+ }
+
+ executableCommand.executeCommand(session, command.getArgsList());
+ }
+
+ /**
+ * Parse a JSON command into a ParsedCommand object for easier handling.
+ *
+ * @param unparsedCommand - command as ExtendedJSONObject
+ * @return - null if command is invalid, else return ParsedCommand with
+ * no null attributes.
+ */
+ protected static Command parseCommand(ExtendedJSONObject unparsedCommand) {
+ String type = (String) unparsedCommand.get("command");
+ if (type == null) {
+ return null;
+ }
+
+ try {
+ JSONArray unparsedArgs = unparsedCommand.getArray("args");
+ if (unparsedArgs == null) {
+ return null;
+ }
+
+ return new Command(type, unparsedArgs);
+ } catch (NonArrayJSONException e) {
+ Logger.debug(LOG_TAG, "Unable to parse args array. Invalid command");
+ return null;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ public void sendURIToClientForDisplay(String uri, String clientID, String title, String sender, Context context) {
+ Logger.info(LOG_TAG, "Sending URI to client " + clientID + ".");
+ if (Logger.LOG_PERSONAL_INFORMATION) {
+ Logger.pii(LOG_TAG, "URI is " + uri + "; title is '" + title + "'.");
+ }
+
+ final JSONArray args = new JSONArray();
+ args.add(uri);
+ args.add(sender);
+ args.add(title);
+
+ final Command displayURICommand = new Command("displayURI", args);
+ this.sendCommand(clientID, displayURICommand, context);
+ }
+
+ /**
+ * Validates and sends a command to a client or all clients.
+ *
+ * Calling this does not actually sync the command data to the server. If the
+ * client already has the command/args pair, it won't receive a duplicate
+ * command.
+ *
+ * @param clientID
+ * Client ID to send command to. If null, send to all remote
+ * clients.
+ * @param command
+ * Command to invoke on remote clients
+ */
+ public void sendCommand(String clientID, Command command, Context context) {
+ Logger.debug(LOG_TAG, "In sendCommand.");
+
+ CommandRunner commandData = commands.get(command.commandType);
+
+ // Don't send commands that we don't know about.
+ if (commandData == null) {
+ Logger.error(LOG_TAG, "Unknown command to send: " + command);
+ return;
+ }
+
+ // Don't send a command with the wrong number of arguments.
+ if (!commandData.argumentsAreValid(command.getArgsList())) {
+ Logger.error(LOG_TAG, "Expected " + commandData.argCount + " args for '" +
+ command + "', but got " + command.args);
+ return;
+ }
+
+ if (clientID != null) {
+ this.sendCommandToClient(clientID, command, context);
+ return;
+ }
+
+ ClientsDatabaseAccessor db = new ClientsDatabaseAccessor(context);
+ try {
+ Map<String, ClientRecord> clientMap = db.fetchAllClients();
+ for (ClientRecord client : clientMap.values()) {
+ this.sendCommandToClient(client.guid, command, context);
+ }
+ } catch (NullCursorException e) {
+ Logger.error(LOG_TAG, "NullCursorException when fetching all GUIDs");
+ } finally {
+ db.close();
+ }
+ }
+
+ protected void sendCommandToClient(String clientID, Command command, Context context) {
+ Logger.info(LOG_TAG, "Sending " + command.commandType + " to " + clientID);
+
+ ClientsDatabaseAccessor db = new ClientsDatabaseAccessor(context);
+ try {
+ db.store(clientID, command);
+ } catch (NullCursorException e) {
+ Logger.error(LOG_TAG, "NullCursorException: Unable to send command.");
+ } finally {
+ db.close();
+ }
+ }
+
+ public static void displayURI(final List<String> args, final Context context) {
+ // We trust the client sender that these exist.
+ final String uri = args.get(0);
+ final String clientId = args.get(1);
+ Logger.pii(LOG_TAG, "Received a URI for display: " + uri + " from " + clientId);
+
+ if (uri == null) {
+ Logger.pii(LOG_TAG, "URI is null – ignoring");
+ return;
+ }
+
+ String title = null;
+ if (args.size() == 3) {
+ title = args.get(2);
+ }
+
+ final Intent sendTabNotificationIntent = new Intent();
+ sendTabNotificationIntent.setClassName(context, BrowserContract.TAB_RECEIVED_SERVICE_CLASS_NAME);
+ sendTabNotificationIntent.setData(Uri.parse(uri));
+ sendTabNotificationIntent.putExtra(Intent.EXTRA_TITLE, title);
+ sendTabNotificationIntent.putExtra(BrowserContract.EXTRA_CLIENT_GUID, clientId);
+ final ComponentName componentName = context.startService(sendTabNotificationIntent);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandRunner.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandRunner.java
new file mode 100644
index 000000000..c7a0f1762
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandRunner.java
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import java.util.List;
+
+public abstract class CommandRunner {
+ public final int argCount;
+
+ public CommandRunner(int argCount) {
+ this.argCount = argCount;
+ }
+
+ public abstract void executeCommand(GlobalSession session, List<String> args);
+
+ public boolean argumentsAreValid(List<String> args) {
+ return args != null &&
+ args.size() == argCount;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CredentialException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CredentialException.java
new file mode 100644
index 000000000..f9004e14c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CredentialException.java
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.content.SyncResult;
+
+/**
+ * There was a problem with the Sync account's credentials: bad username,
+ * missing password, malformed sync key, etc.
+ */
+public abstract class CredentialException extends SyncException {
+ private static final long serialVersionUID = 833010553314100538L;
+
+ public CredentialException() {
+ super();
+ }
+
+ public CredentialException(final Throwable e) {
+ super(e);
+ }
+
+ @Override
+ public void updateStats(GlobalSession globalSession, SyncResult syncResult) {
+ syncResult.stats.numAuthExceptions += 1;
+ }
+
+ /**
+ * No credentials at all.
+ */
+ public static class MissingAllCredentialsException extends CredentialException {
+ private static final long serialVersionUID = 3763937096217604611L;
+
+ public MissingAllCredentialsException() {
+ super();
+ }
+
+ public MissingAllCredentialsException(final Throwable e) {
+ super(e);
+ }
+ }
+
+ /**
+ * Some credential is missing.
+ */
+ public static class MissingCredentialException extends CredentialException {
+ private static final long serialVersionUID = -7543031216547596248L;
+
+ public final String missingCredential;
+
+ public MissingCredentialException(final String missingCredential) {
+ this.missingCredential = missingCredential;
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CryptoRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CryptoRecord.java
new file mode 100644
index 000000000..65563d344
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CryptoRecord.java
@@ -0,0 +1,255 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+
+import org.json.simple.JSONObject;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.CryptoInfo;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.crypto.MissingCryptoInputException;
+import org.mozilla.gecko.sync.crypto.NoKeyBundleException;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+import org.mozilla.gecko.sync.repositories.domain.RecordParseException;
+
+/**
+ * A Sync crypto record has:
+ *
+ * <ul>
+ * <li>a collection of fields which are not encrypted (id and collection);</il>
+ * <li>a set of metadata fields (index, modified, ttl);</il>
+ * <li>a payload, which is encrypted and decrypted on request.</il>
+ * </ul>
+ *
+ * The payload flips between being a blob of JSON with hmac/IV/ciphertext
+ * attributes and the cleartext itself.
+ *
+ * Until there's some benefit to the abstraction, we're simply going to call
+ * this <code>CryptoRecord</code>.
+ *
+ * <code>CryptoRecord</code> uses <code>CryptoInfo</code> to do the actual
+ * encryption and decryption.
+ */
+public class CryptoRecord extends Record {
+
+ // JSON related constants.
+ private static final String KEY_ID = "id";
+ private static final String KEY_COLLECTION = "collection";
+ private static final String KEY_PAYLOAD = "payload";
+ private static final String KEY_MODIFIED = "modified";
+ private static final String KEY_SORTINDEX = "sortindex";
+ private static final String KEY_TTL = "ttl";
+ private static final String KEY_CIPHERTEXT = "ciphertext";
+ private static final String KEY_HMAC = "hmac";
+ private static final String KEY_IV = "IV";
+
+ /**
+ * Helper method for doing actual decryption.
+ *
+ * Input: JSONObject containing a valid payload (cipherText, IV, HMAC),
+ * KeyBundle with keys for decryption. Output: byte[] clearText
+ * @throws CryptoException
+ * @throws UnsupportedEncodingException
+ */
+ private static byte[] decryptPayload(ExtendedJSONObject payload, KeyBundle keybundle) throws CryptoException, UnsupportedEncodingException {
+ byte[] ciphertext = Base64.decodeBase64(((String) payload.get(KEY_CIPHERTEXT)).getBytes("UTF-8"));
+ byte[] iv = Base64.decodeBase64(((String) payload.get(KEY_IV)).getBytes("UTF-8"));
+ byte[] hmac = Utils.hex2Byte((String) payload.get(KEY_HMAC));
+
+ return CryptoInfo.decrypt(ciphertext, iv, hmac, keybundle).getMessage();
+ }
+
+ // The encrypted JSON body object.
+ // The decrypted JSON body object. Fields are copied from `body`.
+
+ public ExtendedJSONObject payload;
+ public KeyBundle keyBundle;
+
+ /**
+ * Don't forget to set cleartext or body!
+ */
+ public CryptoRecord() {
+ super(null, null, 0, false);
+ }
+
+ public CryptoRecord(ExtendedJSONObject payload) {
+ super(null, null, 0, false);
+ if (payload == null) {
+ throw new IllegalArgumentException(
+ "No payload provided to CryptoRecord constructor.");
+ }
+ this.payload = payload;
+ }
+
+ public CryptoRecord(String jsonString) throws IOException, NonObjectJSONException {
+
+ this(new ExtendedJSONObject(jsonString));
+ }
+
+ /**
+ * Create a new CryptoRecord with the same metadata as an existing record.
+ *
+ * @param source
+ */
+ public CryptoRecord(Record source) {
+ super(source.guid, source.collection, source.lastModified, source.deleted);
+ this.ttl = source.ttl;
+ }
+
+ @Override
+ public Record copyWithIDs(String guid, long androidID) {
+ CryptoRecord out = new CryptoRecord(this);
+ out.guid = guid;
+ out.androidID = androidID;
+ out.sortIndex = this.sortIndex;
+ out.ttl = this.ttl;
+ out.payload = (this.payload == null) ? null : new ExtendedJSONObject(this.payload.object);
+ out.keyBundle = this.keyBundle; // TODO: copy me?
+ return out;
+ }
+
+ /**
+ * Take a whole record as JSON -- i.e., something like
+ *
+ * {"payload": "{...}", "id":"foobarbaz"}
+ *
+ * and turn it into a CryptoRecord object.
+ *
+ * @param jsonRecord
+ * @return
+ * A CryptoRecord that encapsulates the provided record.
+ *
+ * @throws NonObjectJSONException
+ * @throws IOException
+ */
+ public static CryptoRecord fromJSONRecord(String jsonRecord)
+ throws NonObjectJSONException, IOException, RecordParseException {
+ byte[] bytes = jsonRecord.getBytes("UTF-8");
+ ExtendedJSONObject object = ExtendedJSONObject.parseUTF8AsJSONObject(bytes);
+
+ return CryptoRecord.fromJSONRecord(object);
+ }
+
+ // TODO: defensive programming.
+ public static CryptoRecord fromJSONRecord(ExtendedJSONObject jsonRecord)
+ throws IOException, NonObjectJSONException, RecordParseException {
+ String id = (String) jsonRecord.get(KEY_ID);
+ String collection = (String) jsonRecord.get(KEY_COLLECTION);
+ String jsonEncodedPayload = (String) jsonRecord.get(KEY_PAYLOAD);
+
+ ExtendedJSONObject payload = new ExtendedJSONObject(jsonEncodedPayload);
+
+ CryptoRecord record = new CryptoRecord(payload);
+ record.guid = id;
+ record.collection = collection;
+ if (jsonRecord.containsKey(KEY_MODIFIED)) {
+ Long timestamp = jsonRecord.getTimestamp(KEY_MODIFIED);
+ if (timestamp == null) {
+ throw new RecordParseException("timestamp could not be parsed");
+ }
+ record.lastModified = timestamp;
+ }
+ if (jsonRecord.containsKey(KEY_SORTINDEX)) {
+ // getLong tries to cast to Long, and might return null. We catch all
+ // exceptions, just to be safe.
+ try {
+ record.sortIndex = jsonRecord.getLong(KEY_SORTINDEX);
+ } catch (Exception e) {
+ throw new RecordParseException("timestamp could not be parsed");
+ }
+ }
+ if (jsonRecord.containsKey(KEY_TTL)) {
+ // TTLs are never returned by the sync server, so should never be true if
+ // the record was fetched.
+ try {
+ record.ttl = jsonRecord.getLong(KEY_TTL);
+ } catch (Exception e) {
+ throw new RecordParseException("TTL could not be parsed");
+ }
+ }
+ // TODO: deleted?
+ return record;
+ }
+
+ public void setKeyBundle(KeyBundle bundle) {
+ this.keyBundle = bundle;
+ }
+
+ public CryptoRecord decrypt() throws CryptoException, IOException, NonObjectJSONException {
+ if (keyBundle == null) {
+ throw new NoKeyBundleException();
+ }
+
+ // Check that payload contains all pieces for crypto.
+ if (!payload.containsKey(KEY_CIPHERTEXT) ||
+ !payload.containsKey(KEY_IV) ||
+ !payload.containsKey(KEY_HMAC)) {
+ throw new MissingCryptoInputException();
+ }
+
+ // There's no difference between handling the crypto/keys object and
+ // anything else; we just get this.keyBundle from a different source.
+ byte[] cleartext = decryptPayload(payload, keyBundle);
+ payload = ExtendedJSONObject.parseUTF8AsJSONObject(cleartext);
+ return this;
+ }
+
+ public CryptoRecord encrypt() throws CryptoException, UnsupportedEncodingException {
+ if (this.keyBundle == null) {
+ throw new NoKeyBundleException();
+ }
+ String cleartext = payload.toJSONString();
+ byte[] cleartextBytes = cleartext.getBytes("UTF-8");
+ CryptoInfo info = CryptoInfo.encrypt(cleartextBytes, keyBundle);
+ String message = new String(Base64.encodeBase64(info.getMessage()));
+ String iv = new String(Base64.encodeBase64(info.getIV()));
+ String hmac = Utils.byte2Hex(info.getHMAC());
+ ExtendedJSONObject ciphertext = new ExtendedJSONObject();
+ ciphertext.put(KEY_CIPHERTEXT, message);
+ ciphertext.put(KEY_HMAC, hmac);
+ ciphertext.put(KEY_IV, iv);
+ this.payload = ciphertext;
+ return this;
+ }
+
+ @Override
+ public void initFromEnvelope(CryptoRecord payload) {
+ throw new IllegalStateException("Can't do this with a CryptoRecord.");
+ }
+
+ @Override
+ public CryptoRecord getEnvelope() {
+ throw new IllegalStateException("Can't do this with a CryptoRecord.");
+ }
+
+ @Override
+ protected void populatePayload(ExtendedJSONObject payload) {
+ throw new IllegalStateException("Can't do this with a CryptoRecord.");
+ }
+
+ @Override
+ protected void initFromPayload(ExtendedJSONObject payload) {
+ throw new IllegalStateException("Can't do this with a CryptoRecord.");
+ }
+
+ // TODO: this only works with encrypted object, and has other limitations.
+ public JSONObject toJSONObject() {
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put(KEY_PAYLOAD, payload.toJSONString());
+ o.put(KEY_ID, this.guid);
+ if (this.ttl > 0) {
+ o.put(KEY_TTL, this.ttl);
+ }
+ return o.object;
+ }
+
+ @Override
+ public String toJSONString() {
+ return toJSONObject().toJSONString();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/DelayedWorkTracker.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/DelayedWorkTracker.java
new file mode 100644
index 000000000..ddcb5411c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/DelayedWorkTracker.java
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+/**
+ * A little class to allow us to maintain a count of extant
+ * things (in our case, callbacks that need to fire), and
+ * some work that we want done when that count hits 0.
+ *
+ * @author rnewman
+ *
+ */
+public class DelayedWorkTracker {
+ private static final String LOG_TAG = "DelayedWorkTracker";
+ protected Runnable workItem = null;
+ protected int outstandingCount = 0;
+
+ public int incrementOutstanding() {
+ Logger.trace(LOG_TAG, "Incrementing outstanding.");
+ synchronized(this) {
+ return ++outstandingCount;
+ }
+ }
+ public int decrementOutstanding() {
+ Logger.trace(LOG_TAG, "Decrementing outstanding.");
+ Runnable job = null;
+ int count;
+ synchronized(this) {
+ if ((count = --outstandingCount) == 0 &&
+ workItem != null) {
+ job = workItem;
+ workItem = null;
+ } else {
+ return count;
+ }
+ }
+ job.run();
+ // In case it's changed.
+ return getOutstandingOperations();
+ }
+ public int getOutstandingOperations() {
+ synchronized(this) {
+ return outstandingCount;
+ }
+ }
+ public void delayWorkItem(Runnable item) {
+ Logger.trace(LOG_TAG, "delayWorkItem.");
+ boolean runnableNow = false;
+ synchronized(this) {
+ Logger.trace(LOG_TAG, "outstandingCount: " + outstandingCount);
+ if (outstandingCount == 0) {
+ runnableNow = true;
+ } else {
+ if (workItem != null) {
+ throw new IllegalStateException("Work item already set!");
+ }
+ workItem = item;
+ }
+ }
+ if (runnableNow) {
+ Logger.trace(LOG_TAG, "Running item now.");
+ item.run();
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/EngineSettings.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/EngineSettings.java
new file mode 100644
index 000000000..035816088
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/EngineSettings.java
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+public class EngineSettings {
+ public final String syncID;
+ public final int version;
+
+ public EngineSettings(final String syncID, final int version) {
+ this.syncID = syncID;
+ this.version = version;
+ }
+
+ public EngineSettings(ExtendedJSONObject object) {
+ try {
+ this.syncID = object.getString("syncID");
+ this.version = object.getIntegerSafely("version");
+ } catch (Exception e ) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ public ExtendedJSONObject toJSONObject() {
+ ExtendedJSONObject json = new ExtendedJSONObject();
+ json.put("syncID", syncID);
+ json.put("version", version);
+ return json;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ExtendedJSONObject.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ExtendedJSONObject.java
new file mode 100644
index 000000000..f5fac0009
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ExtendedJSONObject.java
@@ -0,0 +1,426 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.json.simple.parser.JSONParser;
+import org.json.simple.parser.ParseException;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.sync.UnexpectedJSONException.BadRequiredFieldJSONException;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+/**
+ * Extend JSONObject to do little things, like, y'know, accessing members.
+ *
+ * @author rnewman
+ *
+ */
+public class ExtendedJSONObject {
+
+ public JSONObject object;
+
+ /**
+ * Return a <code>JSONParser</code> instance for immediate use.
+ * <p>
+ * <code>JSONParser</code> is not thread-safe, so we return a new instance
+ * each call. This is extremely inefficient in execution time and especially
+ * memory use -- each instance allocates a 16kb temporary buffer -- and we
+ * hope to improve matters eventually.
+ */
+ protected static JSONParser getJSONParser() {
+ return new JSONParser();
+ }
+
+ /**
+ * Parse a JSON encoded string.
+ *
+ * @param in <code>Reader</code> over a JSON-encoded input to parse; not
+ * necessarily a JSON object.
+ * @return a regular Java <code>Object</code>.
+ * @throws ParseException
+ * @throws IOException
+ */
+ protected static Object parseRaw(Reader in) throws ParseException, IOException {
+ try {
+ return getJSONParser().parse(in);
+ } catch (Error e) {
+ // Don't be stupid, org.json.simple. Bug 1042929.
+ throw new ParseException(ParseException.ERROR_UNEXPECTED_EXCEPTION, e);
+ }
+ }
+
+ /**
+ * Parse a JSON encoded string.
+ * <p>
+ * You should prefer the streaming interface {@link #parseRaw(Reader)}.
+ *
+ * @param input JSON-encoded input string to parse; not necessarily a JSON object.
+ * @return a regular Java <code>Object</code>.
+ * @throws ParseException
+ */
+ protected static Object parseRaw(String input) throws ParseException {
+ try {
+ return getJSONParser().parse(input);
+ } catch (Error e) {
+ // Don't be stupid, org.json.simple. Bug 1042929.
+ throw new ParseException(ParseException.ERROR_UNEXPECTED_EXCEPTION, e);
+ }
+ }
+
+ /**
+ * Helper method to get a JSON array from a stream.
+ *
+ * @param in <code>Reader</code> over a JSON-encoded array to parse.
+ * @throws ParseException
+ * @throws IOException
+ * @throws NonArrayJSONException if the object is valid JSON, but not an array.
+ */
+ public static JSONArray parseJSONArray(Reader in)
+ throws IOException, ParseException, NonArrayJSONException {
+ Object o = parseRaw(in);
+
+ if (o == null) {
+ return null;
+ }
+
+ if (o instanceof JSONArray) {
+ return (JSONArray) o;
+ }
+
+ throw new NonArrayJSONException("value must be a JSON array");
+ }
+
+ /**
+ * Helper method to get a JSON array from a string.
+ * <p>
+ * You should prefer the stream interface {@link #parseJSONArray(Reader)}.
+ *
+ * @param jsonString input.
+ * @throws IOException
+ * @throws NonArrayJSONException if the object is invalid JSON or not an array.
+ */
+ public static JSONArray parseJSONArray(String jsonString)
+ throws IOException, NonArrayJSONException {
+ Object o = null;
+ try {
+ o = parseRaw(jsonString);
+ } catch (ParseException e) {
+ throw new NonArrayJSONException(e);
+ }
+
+ if (o == null) {
+ return null;
+ }
+
+ if (o instanceof JSONArray) {
+ return (JSONArray) o;
+ }
+
+ throw new NonArrayJSONException("value must be a JSON array");
+ }
+
+ /**
+ * Helper method to get a JSON object from a UTF-8 byte array.
+ *
+ * @param in UTF-8 bytes.
+ * @throws NonObjectJSONException if the object is not valid JSON or not an object.
+ * @throws IOException
+ */
+ public static ExtendedJSONObject parseUTF8AsJSONObject(byte[] in)
+ throws NonObjectJSONException, IOException {
+ return new ExtendedJSONObject(new String(in, "UTF-8"));
+ }
+
+ public ExtendedJSONObject() {
+ this.object = new JSONObject();
+ }
+
+ public ExtendedJSONObject(JSONObject o) {
+ this.object = o;
+ }
+
+ public ExtendedJSONObject(Reader in) throws IOException, NonObjectJSONException {
+ if (in == null) {
+ this.object = new JSONObject();
+ return;
+ }
+
+ Object obj = null;
+ try {
+ obj = parseRaw(in);
+ } catch (ParseException e) {
+ throw new NonObjectJSONException(e);
+ }
+
+ if (obj instanceof JSONObject) {
+ this.object = ((JSONObject) obj);
+ } else {
+ throw new NonObjectJSONException("value must be a JSON object");
+ }
+ }
+
+ public ExtendedJSONObject(String jsonString) throws IOException, NonObjectJSONException {
+ this(jsonString == null ? null : new StringReader(jsonString));
+ }
+
+ @Override
+ public ExtendedJSONObject clone() {
+ return new ExtendedJSONObject((JSONObject) this.object.clone());
+ }
+
+ // Passthrough methods.
+ public Object get(String key) {
+ return this.object.get(key);
+ }
+
+ public long getLong(String key, long def) {
+ if (!object.containsKey(key)) {
+ return def;
+ }
+
+ Long val = getLong(key);
+ if (val == null) {
+ return def;
+ }
+ return val.longValue();
+ }
+
+ public Long getLong(String key) {
+ return (Long) this.get(key);
+ }
+
+ public String getString(String key) {
+ return (String) this.get(key);
+ }
+
+ public Boolean getBoolean(String key) {
+ return (Boolean) this.get(key);
+ }
+
+ /**
+ * Return an Integer if the value for this key is an Integer, Long, or String
+ * that can be parsed as a base 10 Integer.
+ * Passes through null.
+ *
+ * @throws NumberFormatException
+ */
+ public Integer getIntegerSafely(String key) throws NumberFormatException {
+ Object val = this.object.get(key);
+ if (val == null) {
+ return null;
+ }
+ if (val instanceof Integer) {
+ return (Integer) val;
+ }
+ if (val instanceof Long) {
+ return ((Long) val).intValue();
+ }
+ if (val instanceof String) {
+ return Integer.parseInt((String) val, 10);
+ }
+ throw new NumberFormatException("Expecting Integer, got " + val.getClass());
+ }
+
+ /**
+ * Return a server timestamp value as milliseconds since epoch.
+ *
+ * @param key
+ * @return A Long, or null if the value is non-numeric or doesn't exist.
+ */
+ public Long getTimestamp(String key) {
+ Object val = this.object.get(key);
+
+ // This is absurd.
+ if (val instanceof Double) {
+ double millis = ((Double) val) * 1000;
+ return Double.valueOf(millis).longValue();
+ }
+ if (val instanceof Float) {
+ double millis = ((Float) val).doubleValue() * 1000;
+ return Double.valueOf(millis).longValue();
+ }
+ if (val instanceof Number) {
+ // Must be an integral number.
+ return ((Number) val).longValue() * 1000;
+ }
+
+ return null;
+ }
+
+ public boolean containsKey(String key) {
+ return this.object.containsKey(key);
+ }
+
+ public String toJSONString() {
+ return this.object.toJSONString();
+ }
+
+ @Override
+ public String toString() {
+ return this.object.toString();
+ }
+
+ protected void putRaw(String key, Object value) {
+ @SuppressWarnings("unchecked")
+ Map<Object, Object> map = this.object;
+ map.put(key, value);
+ }
+
+ public void put(String key, String value) {
+ this.putRaw(key, value);
+ }
+
+ public void put(String key, boolean value) {
+ this.putRaw(key, value);
+ }
+
+ public void put(String key, long value) {
+ this.putRaw(key, value);
+ }
+
+ public void put(String key, int value) {
+ this.putRaw(key, value);
+ }
+
+ public void put(String key, ExtendedJSONObject value) {
+ this.putRaw(key, value);
+ }
+
+ public void put(String key, JSONArray value) {
+ this.putRaw(key, value);
+ }
+
+ @SuppressWarnings("unchecked")
+ public void putArray(String key, List<String> value) {
+ // Frustratingly inefficient, but there you have it.
+ final JSONArray jsonArray = new JSONArray();
+ jsonArray.addAll(value);
+ this.putRaw(key, jsonArray);
+ }
+
+ /**
+ * Remove key-value pair from JSONObject.
+ *
+ * @param key
+ * to be removed.
+ * @return true if key exists and was removed, false otherwise.
+ */
+ public boolean remove(String key) {
+ Object res = this.object.remove(key);
+ return (res != null);
+ }
+
+ public ExtendedJSONObject getObject(String key) throws NonObjectJSONException {
+ Object o = this.object.get(key);
+ if (o == null) {
+ return null;
+ }
+ if (o instanceof ExtendedJSONObject) {
+ return (ExtendedJSONObject) o;
+ }
+ if (o instanceof JSONObject) {
+ return new ExtendedJSONObject((JSONObject) o);
+ }
+ throw new NonObjectJSONException("value must be a JSON object for key: " + key);
+ }
+
+ @SuppressWarnings("unchecked")
+ public Set<Entry<String, Object>> entrySet() {
+ return this.object.entrySet();
+ }
+
+ @SuppressWarnings("unchecked")
+ public Set<String> keySet() {
+ return this.object.keySet();
+ }
+
+ public org.json.simple.JSONArray getArray(String key) throws NonArrayJSONException {
+ Object o = this.object.get(key);
+ if (o == null) {
+ return null;
+ }
+ if (o instanceof JSONArray) {
+ return (JSONArray) o;
+ }
+ throw new NonArrayJSONException("key must be a JSON array: " + key);
+ }
+
+ public int size() {
+ return this.object.size();
+ }
+
+ @Override
+ public int hashCode() {
+ if (this.object == null) {
+ return getClass().hashCode();
+ }
+ return this.object.hashCode() ^ getClass().hashCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof ExtendedJSONObject)) {
+ return false;
+ }
+ if (o == this) {
+ return true;
+ }
+ ExtendedJSONObject other = (ExtendedJSONObject) o;
+ if (this.object == null) {
+ return other.object == null;
+ }
+ return this.object.equals(other.object);
+ }
+
+ /**
+ * Throw if keys are missing or values have wrong types.
+ *
+ * @param requiredFields list of required keys.
+ * @param requiredFieldClass class that values must be coercable to; may be null, which means don't check.
+ * @throws UnexpectedJSONException
+ */
+ public void throwIfFieldsMissingOrMisTyped(String[] requiredFields, Class<?> requiredFieldClass) throws BadRequiredFieldJSONException {
+ // Defensive as possible: verify object has expected key(s) with string value.
+ for (String k : requiredFields) {
+ Object value = get(k);
+ if (value == null) {
+ throw new BadRequiredFieldJSONException("Expected key not present in result: " + k);
+ }
+ if (requiredFieldClass != null && !(requiredFieldClass.isInstance(value))) {
+ throw new BadRequiredFieldJSONException("Value for key not an instance of " + requiredFieldClass + ": " + k);
+ }
+ }
+ }
+
+ /**
+ * Return a base64-encoded string value as a byte array.
+ */
+ public byte[] getByteArrayBase64(String key) {
+ String s = (String) this.object.get(key);
+ if (s == null) {
+ return null;
+ }
+ return Base64.decodeBase64(s);
+ }
+
+ /**
+ * Return a hex-encoded string value as a byte array.
+ */
+ public byte[] getByteArrayHex(String key) {
+ String s = (String) this.object.get(key);
+ if (s == null) {
+ return null;
+ }
+ return Utils.hex2Byte(s);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java
new file mode 100644
index 000000000..e28bbe4cc
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java
@@ -0,0 +1,1167 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.content.Context;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
+import org.mozilla.gecko.sync.delegates.FreshStartDelegate;
+import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
+import org.mozilla.gecko.sync.delegates.JSONRecordFetchDelegate;
+import org.mozilla.gecko.sync.delegates.KeyUploadDelegate;
+import org.mozilla.gecko.sync.delegates.MetaGlobalDelegate;
+import org.mozilla.gecko.sync.delegates.WipeServerDelegate;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.HttpResponseObserver;
+import org.mozilla.gecko.sync.net.SyncResponse;
+import org.mozilla.gecko.sync.net.SyncStorageRecordRequest;
+import org.mozilla.gecko.sync.net.SyncStorageRequest;
+import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.stage.AndroidBrowserBookmarksServerSyncStage;
+import org.mozilla.gecko.sync.stage.AndroidBrowserHistoryServerSyncStage;
+import org.mozilla.gecko.sync.stage.CheckPreconditionsStage;
+import org.mozilla.gecko.sync.stage.CompletedStage;
+import org.mozilla.gecko.sync.stage.EnsureCrypto5KeysStage;
+import org.mozilla.gecko.sync.stage.FennecTabsServerSyncStage;
+import org.mozilla.gecko.sync.stage.FetchInfoCollectionsStage;
+import org.mozilla.gecko.sync.stage.FetchInfoConfigurationStage;
+import org.mozilla.gecko.sync.stage.FetchMetaGlobalStage;
+import org.mozilla.gecko.sync.stage.FormHistoryServerSyncStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+import org.mozilla.gecko.sync.stage.NoSuchStageException;
+import org.mozilla.gecko.sync.stage.PasswordsServerSyncStage;
+import org.mozilla.gecko.sync.stage.SyncClientsEngineStage;
+import org.mozilla.gecko.sync.stage.UploadMetaGlobalStage;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
+
+public class GlobalSession implements HttpResponseObserver {
+ private static final String LOG_TAG = "GlobalSession";
+
+ public static final long STORAGE_VERSION = 5;
+
+ public SyncConfiguration config = null;
+
+ protected Map<Stage, GlobalSyncStage> stages;
+ public Stage currentState = Stage.idle;
+
+ public final GlobalSessionCallback callback;
+ protected final Context context;
+ protected final ClientsDataDelegate clientsDelegate;
+
+ /**
+ * Map from engine name to new settings for an updated meta/global record.
+ * Engines to remove will have <code>null</code> EngineSettings.
+ */
+ public final Map<String, EngineSettings> enginesToUpdate = new HashMap<String, EngineSettings>();
+
+ /*
+ * Key accessors.
+ */
+ public KeyBundle keyBundleForCollection(String collection) throws NoCollectionKeysSetException {
+ return config.getCollectionKeys().keyBundleForCollection(collection);
+ }
+
+ /*
+ * Config passthrough for convenience.
+ */
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return config.getAuthHeaderProvider();
+ }
+
+ public URI wboURI(String collection, String id) throws URISyntaxException {
+ return config.wboURI(collection, id);
+ }
+
+ public GlobalSession(SyncConfiguration config,
+ GlobalSessionCallback callback,
+ Context context,
+ ClientsDataDelegate clientsDelegate)
+ throws SyncConfigurationException, IllegalArgumentException, IOException, NonObjectJSONException {
+
+ if (callback == null) {
+ throw new IllegalArgumentException("Must provide a callback to GlobalSession constructor.");
+ }
+
+ this.callback = callback;
+ this.context = context;
+ this.clientsDelegate = clientsDelegate;
+
+ this.config = config;
+ registerCommands();
+ prepareStages();
+
+ if (config.stagesToSync == null) {
+ Logger.info(LOG_TAG, "No stages to sync specified; defaulting to all valid engine names.");
+ config.stagesToSync = Collections.unmodifiableCollection(SyncConfiguration.validEngineNames());
+ }
+
+ // TODO: data-driven plan for the sync, referring to prepareStages.
+ }
+
+ /**
+ * Register commands this global session knows how to process.
+ * <p>
+ * Re-registering a command overwrites any existing registration.
+ */
+ protected static void registerCommands() {
+ final CommandProcessor processor = CommandProcessor.getProcessor();
+
+ processor.registerCommand("resetEngine", new CommandRunner(1) {
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ HashSet<String> names = new HashSet<String>();
+ names.add(args.get(0));
+ session.resetStagesByName(names);
+ }
+ });
+
+ processor.registerCommand("resetAll", new CommandRunner(0) {
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ session.resetAllStages();
+ }
+ });
+
+ processor.registerCommand("wipeEngine", new CommandRunner(1) {
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ HashSet<String> names = new HashSet<String>();
+ names.add(args.get(0));
+ session.wipeStagesByName(names);
+ }
+ });
+
+ processor.registerCommand("wipeAll", new CommandRunner(0) {
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ session.wipeAllStages();
+ }
+ });
+
+ processor.registerCommand("displayURI", new CommandRunner(3) {
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ CommandProcessor.displayURI(args, session.getContext());
+ }
+ });
+ }
+
+ protected void prepareStages() {
+ Map<Stage, GlobalSyncStage> stages = new EnumMap<Stage, GlobalSyncStage>(Stage.class);
+
+ stages.put(Stage.checkPreconditions, new CheckPreconditionsStage());
+ stages.put(Stage.fetchInfoCollections, new FetchInfoCollectionsStage());
+ stages.put(Stage.fetchMetaGlobal, new FetchMetaGlobalStage());
+ stages.put(Stage.fetchInfoConfiguration, new FetchInfoConfigurationStage(
+ config.infoConfigurationURL(), getAuthHeaderProvider()));
+ stages.put(Stage.ensureKeysStage, new EnsureCrypto5KeysStage());
+
+ stages.put(Stage.syncClientsEngine, new SyncClientsEngineStage());
+
+ stages.put(Stage.syncTabs, new FennecTabsServerSyncStage());
+ stages.put(Stage.syncPasswords, new PasswordsServerSyncStage());
+ stages.put(Stage.syncBookmarks, new AndroidBrowserBookmarksServerSyncStage());
+ stages.put(Stage.syncHistory, new AndroidBrowserHistoryServerSyncStage());
+ stages.put(Stage.syncFormHistory, new FormHistoryServerSyncStage());
+
+ stages.put(Stage.uploadMetaGlobal, new UploadMetaGlobalStage());
+ stages.put(Stage.completed, new CompletedStage());
+
+ this.stages = Collections.unmodifiableMap(stages);
+ }
+
+ public GlobalSyncStage getSyncStageByName(String name) throws NoSuchStageException {
+ return getSyncStageByName(Stage.byName(name));
+ }
+
+ public GlobalSyncStage getSyncStageByName(Stage next) throws NoSuchStageException {
+ GlobalSyncStage stage = stages.get(next);
+ if (stage == null) {
+ throw new NoSuchStageException(next);
+ }
+ return stage;
+ }
+
+ public Collection<GlobalSyncStage> getSyncStagesByEnum(Collection<Stage> enums) {
+ ArrayList<GlobalSyncStage> out = new ArrayList<GlobalSyncStage>();
+ for (Stage name : enums) {
+ try {
+ GlobalSyncStage stage = this.getSyncStageByName(name);
+ out.add(stage);
+ } catch (NoSuchStageException e) {
+ Logger.warn(LOG_TAG, "Unable to find stage with name " + name);
+ }
+ }
+ return out;
+ }
+
+ public Collection<GlobalSyncStage> getSyncStagesByName(Collection<String> names) {
+ ArrayList<GlobalSyncStage> out = new ArrayList<GlobalSyncStage>();
+ for (String name : names) {
+ try {
+ GlobalSyncStage stage = this.getSyncStageByName(name);
+ out.add(stage);
+ } catch (NoSuchStageException e) {
+ Logger.warn(LOG_TAG, "Unable to find stage with name " + name);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Advance and loop around the stages of a sync.
+ * @param current
+ * @return
+ * The next stage to execute.
+ */
+ public static Stage nextStage(Stage current) {
+ int index = current.ordinal() + 1;
+ int max = Stage.completed.ordinal() + 1;
+ return Stage.values()[index % max];
+ }
+
+ /**
+ * Move to the next stage in the syncing process.
+ */
+ public void advance() {
+ // If we have a backoff, request a backoff and don't advance to next stage.
+ long existingBackoff = largestBackoffObserved.get();
+ if (existingBackoff > 0) {
+ this.abort(null, "Aborting sync because of backoff of " + existingBackoff + " milliseconds.");
+ return;
+ }
+
+ this.callback.handleStageCompleted(this.currentState, this);
+ Stage next = nextStage(this.currentState);
+ GlobalSyncStage nextStage;
+ try {
+ nextStage = this.getSyncStageByName(next);
+ } catch (NoSuchStageException e) {
+ this.abort(e, "No such stage " + next);
+ return;
+ }
+ this.currentState = next;
+ Logger.info(LOG_TAG, "Running next stage " + next + " (" + nextStage + ")...");
+ try {
+ nextStage.execute(this);
+ } catch (Exception ex) {
+ Logger.warn(LOG_TAG, "Caught exception " + ex + " running stage " + next);
+ this.abort(ex, "Uncaught exception in stage.");
+ return;
+ }
+ }
+
+ public Context getContext() {
+ return this.context;
+ }
+
+ /**
+ * Begin a sync.
+ * <p>
+ * The caller is responsible for:
+ * <ul>
+ * <li>Verifying that any backoffs/minimum next sync requests are respected.</li>
+ * <li>Ensuring that the device is online.</li>
+ * <li>Ensuring that dependencies are ready.</li>
+ * </ul>
+ *
+ * @throws AlreadySyncingException
+ */
+ public void start() throws AlreadySyncingException {
+ if (this.currentState != GlobalSyncStage.Stage.idle) {
+ throw new AlreadySyncingException(this.currentState);
+ }
+ installAsHttpResponseObserver(); // Uninstalled by completeSync or abort.
+ this.advance();
+ }
+
+ /**
+ * Stop this sync and start again.
+ * @throws AlreadySyncingException
+ */
+ protected void restart() throws AlreadySyncingException {
+ this.currentState = GlobalSyncStage.Stage.idle;
+ if (callback.shouldBackOffStorage()) {
+ this.callback.handleAborted(this, "Told to back off.");
+ return;
+ }
+ this.start();
+ }
+
+ /**
+ * We're finished (aborted or succeeded): release resources.
+ */
+ protected void cleanUp() {
+ uninstallAsHttpResponseObserver();
+ this.stages = null;
+ }
+
+ public void completeSync() {
+ cleanUp();
+ this.currentState = GlobalSyncStage.Stage.idle;
+ this.callback.handleSuccess(this);
+ }
+
+ /**
+ * Record that an updated meta/global record should be uploaded with the given
+ * settings for the given engine.
+ *
+ * @param engineName engine to update.
+ * @param engineSettings new syncID and version.
+ */
+ public void recordForMetaGlobalUpdate(String engineName, EngineSettings engineSettings) {
+ enginesToUpdate.put(engineName, engineSettings);
+ }
+
+ /**
+ * Record that an updated meta/global record should be uploaded without the
+ * given engine name.
+ *
+ * @param engineName
+ * engine to remove.
+ */
+ public void removeEngineFromMetaGlobal(String engineName) {
+ enginesToUpdate.put(engineName, null);
+ }
+
+ public boolean hasUpdatedMetaGlobal() {
+ if (enginesToUpdate.isEmpty()) {
+ Logger.info(LOG_TAG, "Not uploading updated meta/global record since there are no engines requesting upload.");
+ return false;
+ }
+
+ if (Logger.shouldLogVerbose(LOG_TAG)) {
+ Logger.trace(LOG_TAG, "Uploading updated meta/global record since there are engine changes to meta/global.");
+ Logger.trace(LOG_TAG, "Engines requesting update [" + Utils.toCommaSeparatedString(enginesToUpdate.keySet()) + "]");
+ }
+
+ return true;
+ }
+
+ public void updateMetaGlobalInPlace() {
+ config.metaGlobal.declined = this.declinedEngineNames();
+ ExtendedJSONObject engines = config.metaGlobal.getEngines();
+ for (Entry<String, EngineSettings> pair : enginesToUpdate.entrySet()) {
+ if (pair.getValue() == null) {
+ engines.remove(pair.getKey());
+ } else {
+ engines.put(pair.getKey(), pair.getValue().toJSONObject());
+ }
+ }
+
+ enginesToUpdate.clear();
+ }
+
+ /**
+ * Synchronously upload an updated meta/global.
+ * <p>
+ * All problems are logged and ignored.
+ */
+ public void uploadUpdatedMetaGlobal() {
+ updateMetaGlobalInPlace();
+
+ Logger.debug(LOG_TAG, "Uploading updated meta/global record.");
+ final Object monitor = new Object();
+
+ Runnable doUpload = new Runnable() {
+ @Override
+ public void run() {
+ config.metaGlobal.upload(new MetaGlobalDelegate() {
+ @Override
+ public void handleSuccess(MetaGlobal global, SyncStorageResponse response) {
+ Logger.info(LOG_TAG, "Successfully uploaded updated meta/global record.");
+ // Engine changes are stored as diffs, so update enabled engines in config to match uploaded meta/global.
+ config.enabledEngineNames = config.metaGlobal.getEnabledEngineNames();
+ // Clear userSelectedEngines because they are updated in config and meta/global.
+ config.userSelectedEngines = null;
+
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void handleMissing(MetaGlobal global, SyncStorageResponse response) {
+ Logger.warn(LOG_TAG, "Got 404 missing uploading updated meta/global record; shouldn't happen. Ignoring.");
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void handleFailure(SyncStorageResponse response) {
+ Logger.warn(LOG_TAG, "Failed to upload updated meta/global record; ignoring.");
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ Logger.warn(LOG_TAG, "Got exception trying to upload updated meta/global record; ignoring.", e);
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+ });
+ }
+ };
+
+ final Thread upload = new Thread(doUpload);
+ synchronized (monitor) {
+ try {
+ upload.start();
+ monitor.wait();
+ Logger.debug(LOG_TAG, "Uploaded updated meta/global record.");
+ } catch (InterruptedException e) {
+ Logger.error(LOG_TAG, "Uploading updated meta/global interrupted; continuing.");
+ }
+ }
+ }
+
+
+ public void abort(Exception e, String reason) {
+ Logger.warn(LOG_TAG, "Aborting sync: " + reason, e);
+ cleanUp();
+ long existingBackoff = largestBackoffObserved.get();
+ if (existingBackoff > 0) {
+ callback.requestBackoff(existingBackoff);
+ }
+ if (!(e instanceof HTTPFailureException)) {
+ // e is null, or we aborted for a non-HTTP reason; okay to upload new meta/global record.
+ if (this.hasUpdatedMetaGlobal()) {
+ this.uploadUpdatedMetaGlobal(); // Only logs errors; does not call abort.
+ }
+ }
+ this.callback.handleError(this, e);
+ }
+
+ public void handleHTTPError(SyncStorageResponse response, String reason) {
+ // TODO: handling of 50x (backoff), 401 (node reassignment or auth error).
+ // Fall back to aborting.
+ Logger.warn(LOG_TAG, "Aborting sync due to HTTP " + response.getStatusCode());
+ this.interpretHTTPFailure(response.httpResponse());
+ this.abort(new HTTPFailureException(response), reason);
+ }
+
+ /**
+ * Perform appropriate backoff etc. extraction.
+ */
+ public void interpretHTTPFailure(HttpResponse response) {
+ // TODO: handle permanent rejection.
+ long responseBackoff = (new SyncResponse(response)).totalBackoffInMilliseconds();
+ if (responseBackoff > 0) {
+ callback.requestBackoff(responseBackoff);
+ }
+
+ if (response.getStatusLine() != null) {
+ final int statusCode = response.getStatusLine().getStatusCode();
+ switch(statusCode) {
+
+ case 400:
+ SyncStorageResponse storageResponse = new SyncStorageResponse(response);
+ this.interpretHTTPBadRequestBody(storageResponse);
+ break;
+
+ case 401:
+ /*
+ * Alert our callback we have a 401 on a cluster URL. This GlobalSession
+ * will fail, but the next one will fetch a new cluster URL and will
+ * distinguish between "node reassignment" and "user password changed".
+ */
+ callback.informUnauthorizedResponse(this, config.getClusterURL());
+ break;
+ }
+ }
+ }
+
+ protected void interpretHTTPBadRequestBody(final SyncStorageResponse storageResponse) {
+ try {
+ final String body = storageResponse.body();
+ if (body == null) {
+ return;
+ }
+ if (SyncStorageResponse.RESPONSE_CLIENT_UPGRADE_REQUIRED.equals(body)) {
+ callback.informUpgradeRequiredResponse(this);
+ return;
+ }
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Exception parsing HTTP 400 body.", e);
+ }
+ }
+
+ public void fetchInfoCollections(JSONRecordFetchDelegate callback) throws URISyntaxException {
+ final JSONRecordFetcher fetcher = new JSONRecordFetcher(config.infoCollectionsURL(), getAuthHeaderProvider());
+ fetcher.fetch(callback);
+ }
+
+ /**
+ * Upload new crypto/keys.
+ *
+ * @param keys
+ * new keys.
+ * @param keyUploadDelegate
+ * a delegate.
+ */
+ public void uploadKeys(final CollectionKeys keys,
+ final KeyUploadDelegate keyUploadDelegate) {
+ SyncStorageRecordRequest request;
+ try {
+ request = new SyncStorageRecordRequest(this.config.keysURI());
+ } catch (URISyntaxException e) {
+ keyUploadDelegate.onKeyUploadFailed(e);
+ return;
+ }
+
+ request.delegate = new SyncStorageRequestDelegate() {
+
+ @Override
+ public String ifUnmodifiedSince() {
+ return null;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ Logger.debug(LOG_TAG, "Keys uploaded.");
+ BaseResource.consumeEntity(response); // We don't need the response at all.
+ keyUploadDelegate.onKeysUploaded();
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ Logger.debug(LOG_TAG, "Failed to upload keys.");
+ GlobalSession.this.interpretHTTPFailure(response.httpResponse());
+ BaseResource.consumeEntity(response); // The exception thrown should not need the body of the response.
+ keyUploadDelegate.onKeyUploadFailed(new HTTPFailureException(response));
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ Logger.warn(LOG_TAG, "Got exception trying to upload keys", ex);
+ keyUploadDelegate.onKeyUploadFailed(ex);
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return GlobalSession.this.getAuthHeaderProvider();
+ }
+ };
+
+ // Convert keys to an encrypted crypto record.
+ CryptoRecord keysRecord;
+ try {
+ keysRecord = keys.asCryptoRecord();
+ keysRecord.setKeyBundle(config.syncKeyBundle);
+ keysRecord.encrypt();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception trying creating crypto record from keys", e);
+ keyUploadDelegate.onKeyUploadFailed(e);
+ return;
+ }
+
+ request.put(keysRecord);
+ }
+
+ /*
+ * meta/global callbacks.
+ */
+ public void processMetaGlobal(MetaGlobal global) {
+ config.metaGlobal = global;
+
+ Long storageVersion = global.getStorageVersion();
+ if (storageVersion == null) {
+ Logger.warn(LOG_TAG, "Malformed remote meta/global: could not retrieve remote storage version.");
+ freshStart();
+ return;
+ }
+ if (storageVersion < STORAGE_VERSION) {
+ Logger.warn(LOG_TAG, "Outdated server: reported " +
+ "remote storage version " + storageVersion + " < " +
+ "local storage version " + STORAGE_VERSION);
+ freshStart();
+ return;
+ }
+ if (storageVersion > STORAGE_VERSION) {
+ Logger.warn(LOG_TAG, "Outdated client: reported " +
+ "remote storage version " + storageVersion + " > " +
+ "local storage version " + STORAGE_VERSION);
+ requiresUpgrade();
+ return;
+ }
+ String remoteSyncID = global.getSyncID();
+ if (remoteSyncID == null) {
+ Logger.warn(LOG_TAG, "Malformed remote meta/global: could not retrieve remote syncID.");
+ freshStart();
+ return;
+ }
+ String localSyncID = config.syncID;
+ if (!remoteSyncID.equals(localSyncID)) {
+ Logger.warn(LOG_TAG, "Remote syncID different from local syncID: resetting client and assuming remote syncID.");
+ resetAllStages();
+ config.purgeCryptoKeys();
+ config.syncID = remoteSyncID;
+ }
+ // Compare lastModified timestamps for remote/local engine selection times.
+ Logger.debug(LOG_TAG, "Comparing local engine selection timestamp [" + config.userSelectedEnginesTimestamp + "] to server meta/global timestamp [" + config.persistedMetaGlobal().lastModified() + "].");
+ if (config.userSelectedEnginesTimestamp < config.persistedMetaGlobal().lastModified()) {
+ // Remote has later meta/global timestamp. Don't upload engine changes.
+ config.userSelectedEngines = null;
+ }
+ // Persist enabled engine names.
+ config.enabledEngineNames = global.getEnabledEngineNames();
+ if (config.enabledEngineNames == null) {
+ Logger.warn(LOG_TAG, "meta/global reported no enabled engine names!");
+ } else {
+ if (Logger.shouldLogVerbose(LOG_TAG)) {
+ Logger.trace(LOG_TAG, "Persisting enabled engine names '" +
+ Utils.toCommaSeparatedString(config.enabledEngineNames) + "' from meta/global.");
+ }
+ }
+
+ // Persist declined.
+ // Our declined engines at any point are:
+ // Whatever they were remotely, plus whatever they were locally, less any
+ // engines that were just enabled locally or remotely.
+ // If remote just 'won', our recently enabled list just got cleared.
+ final HashSet<String> allDeclined = new HashSet<String>();
+
+ final Set<String> newRemoteDeclined = global.getDeclinedEngineNames();
+ final Set<String> oldLocalDeclined = config.declinedEngineNames;
+
+ allDeclined.addAll(newRemoteDeclined);
+ allDeclined.addAll(oldLocalDeclined);
+
+ if (config.userSelectedEngines != null) {
+ for (Entry<String, Boolean> selection : config.userSelectedEngines.entrySet()) {
+ if (selection.getValue()) {
+ allDeclined.remove(selection.getKey());
+ }
+ }
+ }
+
+ config.declinedEngineNames = allDeclined;
+ if (config.declinedEngineNames.isEmpty()) {
+ Logger.debug(LOG_TAG, "meta/global reported no declined engine names, and we have none declined locally.");
+ } else {
+ if (Logger.shouldLogVerbose(LOG_TAG)) {
+ Logger.trace(LOG_TAG, "Persisting declined engine names '" +
+ Utils.toCommaSeparatedString(config.declinedEngineNames) + "' from meta/global.");
+ }
+ }
+
+ config.persistToPrefs();
+ advance();
+ }
+
+ public void processMissingMetaGlobal(MetaGlobal global) {
+ freshStart();
+ }
+
+ /**
+ * Do a fresh start then quietly finish the sync, starting another.
+ */
+ public void freshStart() {
+ final GlobalSession globalSession = this;
+ freshStart(this, new FreshStartDelegate() {
+
+ @Override
+ public void onFreshStartFailed(Exception e) {
+ globalSession.abort(e, "Fresh start failed.");
+ }
+
+ @Override
+ public void onFreshStart() {
+ try {
+ Logger.warn(LOG_TAG, "Fresh start succeeded; restarting global session.");
+ globalSession.config.persistToPrefs();
+ globalSession.restart();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception when restarting sync after freshStart.", e);
+ globalSession.abort(e, "Got exception after freshStart.");
+ }
+ }
+ });
+ }
+
+ /**
+ * Clean the server, aborting the current sync.
+ * <p>
+ * <ol>
+ * <li>Wipe the server storage.</li>
+ * <li>Reset all stages and purge cached state: (meta/global and crypto/keys records).</li>
+ * <li>Upload fresh meta/global record.</li>
+ * <li>Upload fresh crypto/keys record.</li>
+ * <li>Restart the sync entirely in order to re-download meta/global and crypto/keys record.</li>
+ * </ol>
+ * @param session the current session.
+ * @param freshStartDelegate delegate to notify on fresh start or failure.
+ */
+ protected static void freshStart(final GlobalSession session, final FreshStartDelegate freshStartDelegate) {
+ Logger.debug(LOG_TAG, "Fresh starting.");
+
+ final MetaGlobal mg = session.generateNewMetaGlobal();
+
+ session.wipeServer(session.getAuthHeaderProvider(), new WipeServerDelegate() {
+
+ @Override
+ public void onWiped(long timestamp) {
+ Logger.debug(LOG_TAG, "Successfully wiped server. Resetting all stages and purging cached meta/global and crypto/keys records.");
+
+ session.resetAllStages();
+ session.config.purgeMetaGlobal();
+ session.config.purgeCryptoKeys();
+ session.config.persistToPrefs();
+
+ Logger.info(LOG_TAG, "Uploading new meta/global with sync ID " + mg.syncID + ".");
+
+ // It would be good to set the X-If-Unmodified-Since header to `timestamp`
+ // for this PUT to ensure at least some level of transactionality.
+ // Unfortunately, the servers don't support it after a wipe right now
+ // (bug 693893), so we're going to defer this until bug 692700.
+ mg.upload(new MetaGlobalDelegate() {
+ @Override
+ public void handleSuccess(MetaGlobal uploadedGlobal, SyncStorageResponse uploadResponse) {
+ Logger.info(LOG_TAG, "Uploaded new meta/global with sync ID " + uploadedGlobal.syncID + ".");
+
+ // Generate new keys.
+ CollectionKeys keys = null;
+ try {
+ keys = session.generateNewCryptoKeys();
+ } catch (CryptoException e) {
+ Logger.warn(LOG_TAG, "Got exception generating new keys; failing fresh start.", e);
+ freshStartDelegate.onFreshStartFailed(e);
+ }
+ if (keys == null) {
+ Logger.warn(LOG_TAG, "Got null keys from generateNewKeys; failing fresh start.");
+ freshStartDelegate.onFreshStartFailed(null);
+ }
+
+ // Upload new keys.
+ Logger.info(LOG_TAG, "Uploading new crypto/keys.");
+ session.uploadKeys(keys, new KeyUploadDelegate() {
+ @Override
+ public void onKeysUploaded() {
+ Logger.info(LOG_TAG, "Uploaded new crypto/keys.");
+ freshStartDelegate.onFreshStart();
+ }
+
+ @Override
+ public void onKeyUploadFailed(Exception e) {
+ Logger.warn(LOG_TAG, "Got exception uploading new keys.", e);
+ freshStartDelegate.onFreshStartFailed(e);
+ }
+ });
+ }
+
+ @Override
+ public void handleMissing(MetaGlobal global, SyncStorageResponse response) {
+ // Shouldn't happen on upload.
+ Logger.warn(LOG_TAG, "Got 'missing' response uploading new meta/global.");
+ freshStartDelegate.onFreshStartFailed(new Exception("meta/global missing while uploading."));
+ }
+
+ @Override
+ public void handleFailure(SyncStorageResponse response) {
+ Logger.warn(LOG_TAG, "Got failure " + response.getStatusCode() + " uploading new meta/global.");
+ session.interpretHTTPFailure(response.httpResponse());
+ freshStartDelegate.onFreshStartFailed(new HTTPFailureException(response));
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ Logger.warn(LOG_TAG, "Got error uploading new meta/global.", e);
+ freshStartDelegate.onFreshStartFailed(e);
+ }
+ });
+ }
+
+ @Override
+ public void onWipeFailed(Exception e) {
+ Logger.warn(LOG_TAG, "Wipe failed.");
+ freshStartDelegate.onFreshStartFailed(e);
+ }
+ });
+ }
+
+ // Note that we do not yet implement wipeRemote: it's only necessary for
+ // first sync options.
+ // -- reset local stages, wipe server for each stage *except* clients
+ // (stages only, not whole server!), send wipeEngine commands to each client.
+ //
+ // Similarly for startOver (because we don't receive that notification).
+ // -- remove client data from server, reset local stages, clear keys, reset
+ // backoff, clear all prefs, discard credentials.
+ //
+ // Change passphrase: wipe entire server, reset client to force upload, sync.
+ //
+ // When an engine is disabled: wipe its collections on the server, reupload
+ // meta/global.
+ //
+ // On syncing each stage: if server has engine version 0 or old, wipe server,
+ // reset client to prompt reupload.
+ // If sync ID mismatch: take that syncID and reset client.
+
+ protected void wipeServer(final AuthHeaderProvider authHeaderProvider, final WipeServerDelegate wipeDelegate) {
+ SyncStorageRequest request;
+ final GlobalSession self = this;
+
+ try {
+ request = new SyncStorageRequest(config.storageURL());
+ } catch (URISyntaxException ex) {
+ Logger.warn(LOG_TAG, "Invalid URI in wipeServer.");
+ wipeDelegate.onWipeFailed(ex);
+ return;
+ }
+
+ request.delegate = new SyncStorageRequestDelegate() {
+
+ @Override
+ public String ifUnmodifiedSince() {
+ return null;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ BaseResource.consumeEntity(response);
+ wipeDelegate.onWiped(response.normalizedWeaveTimestamp());
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ Logger.warn(LOG_TAG, "Got request failure " + response.getStatusCode() + " in wipeServer.");
+ // Process HTTP failures here to pick up backoffs, etc.
+ self.interpretHTTPFailure(response.httpResponse());
+ BaseResource.consumeEntity(response); // The exception thrown should not need the body of the response.
+ wipeDelegate.onWipeFailed(new HTTPFailureException(response));
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ Logger.warn(LOG_TAG, "Got exception in wipeServer.", ex);
+ wipeDelegate.onWipeFailed(ex);
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return GlobalSession.this.getAuthHeaderProvider();
+ }
+ };
+ request.delete();
+ }
+
+ public void wipeAllStages() {
+ Logger.info(LOG_TAG, "Wiping all stages.");
+ // Includes "clients".
+ this.wipeStagesByEnum(Stage.getNamedStages());
+ }
+
+ public void wipeStages(Collection<GlobalSyncStage> stages) {
+ for (GlobalSyncStage stage : stages) {
+ try {
+ Logger.info(LOG_TAG, "Wiping " + stage);
+ stage.wipeLocal(this);
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Ignoring wipe failure for stage " + stage, e);
+ }
+ }
+ }
+
+ public void wipeStagesByEnum(Collection<Stage> stages) {
+ wipeStages(this.getSyncStagesByEnum(stages));
+ }
+
+ public void wipeStagesByName(Collection<String> names) {
+ wipeStages(this.getSyncStagesByName(names));
+ }
+
+ public void resetAllStages() {
+ Logger.info(LOG_TAG, "Resetting all stages.");
+ // Includes "clients".
+ this.resetStagesByEnum(Stage.getNamedStages());
+ }
+
+ public void resetStages(Collection<GlobalSyncStage> stages) {
+ for (GlobalSyncStage stage : stages) {
+ try {
+ Logger.info(LOG_TAG, "Resetting " + stage);
+ stage.resetLocal(this);
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Ignoring reset failure for stage " + stage, e);
+ }
+ }
+ }
+
+ public void resetStagesByEnum(Collection<Stage> stages) {
+ resetStages(this.getSyncStagesByEnum(stages));
+ }
+
+ public void resetStagesByName(Collection<String> names) {
+ resetStages(this.getSyncStagesByName(names));
+ }
+
+ /**
+ * Engines to explicitly mark as declined in a fresh meta/global record.
+ * <p>
+ * Returns an empty array if the user hasn't elected to customize data types,
+ * or an array of engines that the user un-checked during customization.
+ * <p>
+ * Engines that Android Sync doesn't recognize are <b>not</b> included in
+ * the returned array.
+ *
+ * @return a new JSONArray of engine names.
+ */
+ @SuppressWarnings("unchecked")
+ protected JSONArray declinedEngineNames() {
+ final JSONArray declined = new JSONArray();
+ for (String engine : config.declinedEngineNames) {
+ declined.add(engine);
+ };
+
+ return declined;
+ }
+
+ /**
+ * Engines to include in a fresh meta/global record.
+ * <p>
+ * Returns either the persisted engine names (perhaps we have been node
+ * re-assigned and are initializing a clean server: we want to upload the
+ * persisted engine names so that we don't accidentally disable engines that
+ * Android Sync doesn't recognize), or the set of engines names that Android
+ * Sync implements.
+ *
+ * @return set of engine names.
+ */
+ protected Set<String> enabledEngineNames() {
+ if (config.enabledEngineNames != null) {
+ return config.enabledEngineNames;
+ }
+
+ // These are the default set of engine names.
+ Set<String> validEngineNames = SyncConfiguration.validEngineNames();
+
+ // If the user hasn't set any selected engines, that's okay -- default to
+ // everything.
+ if (config.userSelectedEngines == null) {
+ return validEngineNames;
+ }
+
+ // userSelectedEngines has keys that are engine names, and boolean values
+ // corresponding to whether the user asked for the engine to sync or not. If
+ // an engine is not present, that means the user didn't change its sync
+ // setting. Since we default to everything on, that means the user didn't
+ // turn it off; therefore, it's included in the set of engines to sync.
+ Set<String> validAndSelectedEngineNames = new HashSet<String>();
+ for (String engineName : validEngineNames) {
+ if (config.userSelectedEngines.containsKey(engineName) &&
+ !config.userSelectedEngines.get(engineName)) {
+ continue;
+ }
+ validAndSelectedEngineNames.add(engineName);
+ }
+ return validAndSelectedEngineNames;
+ }
+
+ /**
+ * Generate fresh crypto/keys collection.
+ * @return crypto/keys collection.
+ * @throws CryptoException
+ */
+ @SuppressWarnings("static-method")
+ public CollectionKeys generateNewCryptoKeys() throws CryptoException {
+ return CollectionKeys.generateCollectionKeys();
+ }
+
+ /**
+ * Generate a fresh meta/global record.
+ * @return meta/global record.
+ */
+ public MetaGlobal generateNewMetaGlobal() {
+ final String newSyncID = Utils.generateGuid();
+ final String metaURL = this.config.metaURL();
+
+ ExtendedJSONObject engines = new ExtendedJSONObject();
+ for (String engineName : enabledEngineNames()) {
+ EngineSettings engineSettings = null;
+ try {
+ GlobalSyncStage globalStage = this.getSyncStageByName(engineName);
+ Integer version = globalStage.getStorageVersion();
+ if (version == null) {
+ continue; // Don't want this stage to be included in meta/global.
+ }
+ engineSettings = new EngineSettings(Utils.generateGuid(), version);
+ } catch (NoSuchStageException e) {
+ // No trouble; Android Sync might not recognize this engine yet.
+ // By default, version 0. Other clients will see the 0 version and reset/wipe accordingly.
+ engineSettings = new EngineSettings(Utils.generateGuid(), 0);
+ }
+ engines.put(engineName, engineSettings.toJSONObject());
+ }
+
+ MetaGlobal metaGlobal = new MetaGlobal(metaURL, this.getAuthHeaderProvider());
+ metaGlobal.setSyncID(newSyncID);
+ metaGlobal.setStorageVersion(STORAGE_VERSION);
+ metaGlobal.setEngines(engines);
+
+ // We assume that the config's declined engines have been updated
+ // according to the user's selections.
+ metaGlobal.setDeclinedEngineNames(this.declinedEngineNames());
+
+ return metaGlobal;
+ }
+
+ /**
+ * Suggest that your Sync client needs to be upgraded to work
+ * with this server.
+ */
+ public void requiresUpgrade() {
+ Logger.info(LOG_TAG, "Client outdated storage version; requires update.");
+ // TODO: notify UI.
+ this.abort(null, "Requires upgrade");
+ }
+
+ /**
+ * If meta/global is missing or malformed, throws a MetaGlobalException.
+ * Otherwise, returns true if there is an entry for this engine in the
+ * meta/global "engines" object.
+ * <p>
+ * This is a global/permanent setting, not a local/temporary setting. For the
+ * latter, see {@link GlobalSession#isEngineLocallyEnabled(String)}.
+ *
+ * @param engineName the name to check (e.g., "bookmarks").
+ * @param engineSettings
+ * if non-null, verify that the server engine settings are congruent
+ * with this, throwing the appropriate MetaGlobalException if not.
+ * @return
+ * true if the engine with the provided name is present in the
+ * meta/global "engines" object, and verification passed.
+ *
+ * @throws MetaGlobalException
+ */
+ public boolean isEngineRemotelyEnabled(String engineName, EngineSettings engineSettings) throws MetaGlobalException {
+ if (this.config.metaGlobal == null) {
+ throw new MetaGlobalNotSetException();
+ }
+
+ // This should not occur.
+ if (this.config.enabledEngineNames == null) {
+ Logger.error(LOG_TAG, "No enabled engines in config. Giving up.");
+ throw new MetaGlobalMissingEnginesException();
+ }
+
+ if (!(this.config.enabledEngineNames.contains(engineName))) {
+ Logger.debug(LOG_TAG, "Engine " + engineName + " not enabled: no meta/global entry.");
+ return false;
+ }
+
+ // If we have a meta/global, check that it's safe for us to sync.
+ // (If we don't, we'll create one later, which is why we return `true` above.)
+ if (engineSettings != null) {
+ // Throws if there's a problem.
+ this.config.metaGlobal.verifyEngineSettings(engineName, engineSettings);
+ }
+
+ return true;
+ }
+
+
+ /**
+ * Return true if the named stage should be synced this session.
+ * <p>
+ * This is a local/temporary setting, in contrast to the meta/global record,
+ * which is a global/permanent setting. For the latter, see
+ * {@link GlobalSession#isEngineRemotelyEnabled(String, EngineSettings)}.
+ *
+ * @param stageName
+ * to query.
+ * @return true if named stage is enabled for this sync.
+ */
+ public boolean isEngineLocallyEnabled(String stageName) {
+ if (config.stagesToSync == null) {
+ return true;
+ }
+ return config.stagesToSync.contains(stageName);
+ }
+
+ public ClientsDataDelegate getClientsDelegate() {
+ return this.clientsDelegate;
+ }
+
+ /**
+ * The longest backoff observed to date; -1 means no backoff observed.
+ */
+ protected final AtomicLong largestBackoffObserved = new AtomicLong(-1);
+
+ /**
+ * Reset any observed backoff and start observing HTTP responses for backoff
+ * requests.
+ */
+ protected void installAsHttpResponseObserver() {
+ Logger.debug(LOG_TAG, "Adding " + this + " as a BaseResource HttpResponseObserver.");
+ BaseResource.addHttpResponseObserver(this);
+ largestBackoffObserved.set(-1);
+ }
+
+ /**
+ * Stop observing HttpResponses for backoff requests.
+ */
+ protected void uninstallAsHttpResponseObserver() {
+ Logger.debug(LOG_TAG, "Removing " + this + " as a BaseResource HttpResponseObserver.");
+ BaseResource.removeHttpResponseObserver(this);
+ }
+
+ /**
+ * Observe all HTTP response for backoff requests on all status codes, not just errors.
+ */
+ @Override
+ public void observeHttpResponse(HttpUriRequest request, HttpResponse response) {
+ // Ignore non-Sync storage requests.
+ final URI clusterURL = config.getClusterURL();
+ if (clusterURL != null && !clusterURL.getHost().equals(request.getURI().getHost())) {
+ // It's possible to see requests without a clusterURL (in particular,
+ // during testing); allow some extra backoffs in this case.
+ return;
+ }
+
+ long responseBackoff = (new SyncResponse(response)).totalBackoffInMilliseconds(); // TODO: don't allocate object?
+ if (responseBackoff <= 0) {
+ return;
+ }
+
+ Logger.debug(LOG_TAG, "Observed " + responseBackoff + " millisecond backoff request.");
+ while (true) {
+ long existingBackoff = largestBackoffObserved.get();
+ if (existingBackoff >= responseBackoff) {
+ return;
+ }
+ if (largestBackoffObserved.compareAndSet(existingBackoff, responseBackoff)) {
+ return;
+ }
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/HTTPFailureException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/HTTPFailureException.java
new file mode 100644
index 000000000..69bba8841
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/HTTPFailureException.java
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+import android.content.SyncResult;
+
+public class HTTPFailureException extends SyncException {
+ private static final long serialVersionUID = -5415864029780770619L;
+ public SyncStorageResponse response;
+
+ public HTTPFailureException(SyncStorageResponse response) {
+ this.response = response;
+ }
+
+ @Override
+ public String toString() {
+ String errorMessage;
+ try {
+ errorMessage = this.response.getErrorMessage();
+ } catch (Exception e) {
+ // Oh well.
+ errorMessage = "[unknown error message]";
+ }
+ return "<HTTPFailureException " + this.response.getStatusCode() +
+ " :: (" + errorMessage + ")>";
+ }
+
+ @Override
+ public void updateStats(GlobalSession globalSession, SyncResult syncResult) {
+ switch (response.getStatusCode()) {
+ case 401:
+ // Node reassignment 401s get handled internally.
+ syncResult.stats.numAuthExceptions++;
+ return;
+ case 500:
+ case 501:
+ case 503:
+ // TODO: backoff.
+ syncResult.stats.numIoExceptions++;
+ return;
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCollections.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCollections.java
new file mode 100644
index 000000000..374fa5cf5
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCollections.java
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+/**
+ * Fetches the timestamp information in <code>info/collections</code> on the
+ * Sync server. Provides access to those timestamps, along with logic to check
+ * for whether a collection requires an update.
+ */
+public class InfoCollections {
+ private static final String LOG_TAG = "InfoCollections";
+
+ /**
+ * Fields fetched from the server, or <code>null</code> if not yet fetched.
+ * <p>
+ * Rather than storing decimal/double timestamps, as provided by the server,
+ * we convert immediately to milliseconds since epoch.
+ */
+ final Map<String, Long> timestamps;
+
+ public InfoCollections() {
+ this(new ExtendedJSONObject());
+ }
+
+ public InfoCollections(final ExtendedJSONObject record) {
+ Logger.debug(LOG_TAG, "info/collections is " + record.toJSONString());
+ HashMap<String, Long> map = new HashMap<String, Long>();
+
+ for (Entry<String, Object> entry : record.entrySet()) {
+ final String key = entry.getKey();
+ final Object value = entry.getValue();
+
+ // These objects are most likely going to be Doubles. Regardless, we
+ // want to get them in a more sane time format.
+ if (value instanceof Double) {
+ map.put(key, Utils.decimalSecondsToMilliseconds((Double) value));
+ continue;
+ }
+ if (value instanceof Long) {
+ map.put(key, Utils.decimalSecondsToMilliseconds((Long) value));
+ continue;
+ }
+ if (value instanceof Integer) {
+ map.put(key, Utils.decimalSecondsToMilliseconds((Integer) value));
+ continue;
+ }
+ Logger.warn(LOG_TAG, "Skipping info/collections entry for " + key);
+ }
+
+ this.timestamps = Collections.unmodifiableMap(map);
+ }
+
+ /**
+ * Return the timestamp for the given collection, or null if the timestamps
+ * have not been fetched or the given collection does not have a timestamp.
+ *
+ * @param collection
+ * The collection to inspect.
+ * @return the timestamp in milliseconds since epoch.
+ */
+ public Long getTimestamp(String collection) {
+ if (timestamps == null) {
+ return null;
+ }
+ return timestamps.get(collection);
+ }
+
+ /**
+ * Test if a given collection needs to be updated.
+ *
+ * @param collection
+ * The collection to test.
+ * @param lastModified
+ * Timestamp when local record was last modified.
+ */
+ public boolean updateNeeded(String collection, long lastModified) {
+ Logger.trace(LOG_TAG, "Testing " + collection + " for updateNeeded. Local last modified is " + lastModified + ".");
+
+ // No local record of modification time? Need an update.
+ if (lastModified <= 0) {
+ return true;
+ }
+
+ // No meta/global on the server? We need an update. The server fetch will fail and
+ // then we will upload a fresh meta/global.
+ Long serverLastModified = getTimestamp(collection);
+ if (serverLastModified == null) {
+ return true;
+ }
+
+ // Otherwise, we need an update if our modification time is stale.
+ return serverLastModified > lastModified;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoConfiguration.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoConfiguration.java
new file mode 100644
index 000000000..eb2428433
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoConfiguration.java
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.util.Log;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+/**
+ * Wraps and provides access to configuration data returned from info/configuration.
+ * Docs: https://docs.services.mozilla.com/storage/apis-1.5.html#general-info
+ *
+ * - <bold>max_request_bytes</bold>: the maximum size in bytes of the overall
+ * HTTP request body that will be accepted by the server.
+ *
+ * - <bold>max_post_records</bold>: the maximum number of records that can be
+ * uploaded to a collection in a single POST request.
+ *
+ * - <bold>max_post_bytes</bold>: the maximum combined size in bytes of the
+ * record payloads that can be uploaded to a collection in a single
+ * POST request.
+ *
+ * - <bold>max_total_records</bold>: the maximum number of records that can be
+ * uploaded to a collection as part of a batched upload.
+ *
+ * - <bold>max_total_bytes</bold>: the maximum combined size in bytes of the
+ * record payloads that can be uploaded to a collection as part of
+ * a batched upload.
+ */
+public class InfoConfiguration {
+ private static final String LOG_TAG = "InfoConfiguration";
+
+ public static final String MAX_REQUEST_BYTES = "max_request_bytes";
+ public static final String MAX_POST_RECORDS = "max_post_records";
+ public static final String MAX_POST_BYTES = "max_post_bytes";
+ public static final String MAX_TOTAL_RECORDS = "max_total_records";
+ public static final String MAX_TOTAL_BYTES = "max_total_bytes";
+
+ private static final long DEFAULT_MAX_REQUEST_BYTES = 1048576;
+ private static final long DEFAULT_MAX_POST_RECORDS = 100;
+ private static final long DEFAULT_MAX_POST_BYTES = 1048576;
+ private static final long DEFAULT_MAX_TOTAL_RECORDS = 10000;
+ private static final long DEFAULT_MAX_TOTAL_BYTES = 104857600;
+
+ // While int's upper range is (2^31-1), which in bytes is equivalent to 2.147 GB, let's be optimistic
+ // about the future and use long here, so that this code works if the server decides its clients are
+ // all on fiber and have congress-library sized bookmark collections.
+ // Record counts are long for the sake of simplicity.
+ public final long maxRequestBytes;
+ public final long maxPostRecords;
+ public final long maxPostBytes;
+ public final long maxTotalRecords;
+ public final long maxTotalBytes;
+
+ public InfoConfiguration() {
+ Logger.debug(LOG_TAG, "info/configuration is unavailable, using defaults");
+
+ maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES;
+ maxPostRecords = DEFAULT_MAX_POST_RECORDS;
+ maxPostBytes = DEFAULT_MAX_POST_BYTES;
+ maxTotalRecords = DEFAULT_MAX_TOTAL_RECORDS;
+ maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES;
+ }
+
+ public InfoConfiguration(final ExtendedJSONObject record) {
+ Logger.debug(LOG_TAG, "info/configuration is " + record.toJSONString());
+
+ maxRequestBytes = getValueFromRecord(record, MAX_REQUEST_BYTES, DEFAULT_MAX_REQUEST_BYTES);
+ maxPostRecords = getValueFromRecord(record, MAX_POST_RECORDS, DEFAULT_MAX_POST_RECORDS);
+ maxPostBytes = getValueFromRecord(record, MAX_POST_BYTES, DEFAULT_MAX_POST_BYTES);
+ maxTotalRecords = getValueFromRecord(record, MAX_TOTAL_RECORDS, DEFAULT_MAX_TOTAL_RECORDS);
+ maxTotalBytes = getValueFromRecord(record, MAX_TOTAL_BYTES, DEFAULT_MAX_TOTAL_BYTES);
+ }
+
+ private static Long getValueFromRecord(ExtendedJSONObject record, String key, long defaultValue) {
+ if (!record.containsKey(key)) {
+ return defaultValue;
+ }
+
+ try {
+ Long val = record.getLong(key);
+ if (val == null) {
+ return defaultValue;
+ }
+ return val;
+ } catch (NumberFormatException e) {
+ Log.w(LOG_TAG, "Could not parse key " + key + " from record: " + record, e);
+ return defaultValue;
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCounts.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCounts.java
new file mode 100644
index 000000000..832e97d10
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoCounts.java
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+public class InfoCounts {
+ static final String LOG_TAG = "InfoCounts";
+
+ /**
+ * Counts fetched from the server, or <code>null</code> if not yet fetched.
+ */
+ private Map<String, Integer> counts = null;
+
+ @SuppressWarnings("unchecked")
+ public InfoCounts(final ExtendedJSONObject record) {
+ Logger.debug(LOG_TAG, "info/collection_counts is " + record.toJSONString());
+ HashMap<String, Integer> map = new HashMap<String, Integer>();
+
+ Set<Entry<String, Object>> entrySet = record.object.entrySet();
+
+ String key;
+ Object value;
+
+ for (Entry<String, Object> entry : entrySet) {
+ key = entry.getKey();
+ value = entry.getValue();
+
+ if (value instanceof Integer) {
+ map.put(key, (Integer) value);
+ continue;
+ }
+
+ if (value instanceof Long) {
+ map.put(key, ((Long) value).intValue());
+ continue;
+ }
+
+ Logger.warn(LOG_TAG, "Skipping info/collection_counts entry for " + key);
+ }
+
+ this.counts = Collections.unmodifiableMap(map);
+ }
+
+ /**
+ * Return the server count for the given collection, or null if the counts
+ * have not been fetched or the given collection does not have a count.
+ *
+ * @param collection
+ * The collection to inspect.
+ * @return the number of elements in the named collection.
+ */
+ public Integer getCount(String collection) {
+ if (counts == null) {
+ return null;
+ }
+ return counts.get(collection);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/JSONRecordFetcher.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/JSONRecordFetcher.java
new file mode 100644
index 000000000..982b5b026
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/JSONRecordFetcher.java
@@ -0,0 +1,145 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.delegates.JSONRecordFetchDelegate;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageRecordRequest;
+import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+/**
+ * An object which fetches a chunk of JSON from a URI, using certain credentials,
+ * and informs its delegate of the result.
+ */
+public class JSONRecordFetcher {
+ private static final long DEFAULT_AWAIT_TIMEOUT_MSEC = 2 * 60 * 1000; // Two minutes.
+ private static final String LOG_TAG = "JSONRecordFetcher";
+
+ protected final AuthHeaderProvider authHeaderProvider;
+ protected final String uri;
+ protected JSONRecordFetchDelegate delegate;
+
+ public JSONRecordFetcher(final String uri, final AuthHeaderProvider authHeaderProvider) {
+ if (uri == null) {
+ throw new IllegalArgumentException("uri must not be null");
+ }
+ this.uri = uri;
+ this.authHeaderProvider = authHeaderProvider;
+ }
+
+ protected String getURI() {
+ return this.uri;
+ }
+
+ private class JSONFetchHandler implements SyncStorageRequestDelegate {
+
+ // SyncStorageRequestDelegate methods for fetching.
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return authHeaderProvider;
+ }
+
+ @Override
+ public String ifUnmodifiedSince() {
+ return null;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ if (response.wasSuccessful()) {
+ try {
+ delegate.handleSuccess(response.jsonObjectBody());
+ } catch (Exception e) {
+ handleRequestError(e);
+ }
+ return;
+ }
+ handleRequestFailure(response);
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ delegate.handleFailure(response);
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ delegate.handleError(ex);
+ }
+ }
+
+ public void fetch(final JSONRecordFetchDelegate delegate) {
+ this.delegate = delegate;
+ try {
+ final SyncStorageRecordRequest r = new SyncStorageRecordRequest(this.getURI());
+ r.delegate = new JSONFetchHandler();
+ r.get();
+ } catch (Exception e) {
+ delegate.handleError(e);
+ }
+ }
+
+ private class LatchedJSONRecordFetchDelegate implements JSONRecordFetchDelegate {
+ public ExtendedJSONObject body = null;
+ public Exception exception = null;
+ private final CountDownLatch latch;
+
+ public LatchedJSONRecordFetchDelegate(CountDownLatch latch) {
+ this.latch = latch;
+ }
+
+ @Override
+ public void handleFailure(SyncStorageResponse response) {
+ this.exception = new HTTPFailureException(response);
+ latch.countDown();
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ this.exception = e;
+ latch.countDown();
+ }
+
+ @Override
+ public void handleSuccess(ExtendedJSONObject body) {
+ this.body = body;
+ latch.countDown();
+ }
+ }
+
+ /**
+ * Fetch the info record, blocking until it returns.
+ * @return the info record.
+ */
+ public ExtendedJSONObject fetchBlocking() throws HTTPFailureException, Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ LatchedJSONRecordFetchDelegate delegate = new LatchedJSONRecordFetchDelegate(latch);
+ this.delegate = delegate;
+ this.fetch(delegate);
+
+ // Sanity wait: the resource itself will time out and throw after two
+ // minutes, so we just want to avoid coding errors causing us to block
+ // endlessly.
+ if (!latch.await(DEFAULT_AWAIT_TIMEOUT_MSEC, TimeUnit.MILLISECONDS)) {
+ Logger.warn(LOG_TAG, "Interrupted fetching info record.");
+ throw new InterruptedException("info fetch timed out.");
+ }
+
+ if (delegate.body != null) {
+ return delegate.body;
+ }
+
+ if (delegate.exception != null) {
+ throw delegate.exception;
+ }
+
+ throw new Exception("Unknown error.");
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/KeyBundleProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/KeyBundleProvider.java
new file mode 100644
index 000000000..4a2be2a9b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/KeyBundleProvider.java
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+
+public interface KeyBundleProvider {
+ public abstract KeyBundle keyBundle();
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobal.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobal.java
new file mode 100644
index 000000000..a90c0fee8
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobal.java
@@ -0,0 +1,372 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.MetaGlobalException.MetaGlobalMalformedSyncIDException;
+import org.mozilla.gecko.sync.MetaGlobalException.MetaGlobalMalformedVersionException;
+import org.mozilla.gecko.sync.delegates.MetaGlobalDelegate;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageRecordRequest;
+import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+public class MetaGlobal implements SyncStorageRequestDelegate {
+ private static final String LOG_TAG = "MetaGlobal";
+ protected String metaURL;
+
+ // Fields.
+ protected ExtendedJSONObject engines;
+ protected JSONArray declined;
+ protected Long storageVersion;
+ protected String syncID;
+
+ // Lookup tables.
+ protected Map<String, String> syncIDs;
+ protected Map<String, Integer> versions;
+ protected Map<String, MetaGlobalException> exceptions;
+
+ // Temporary location to store our callback.
+ private MetaGlobalDelegate callback;
+
+ // A little hack so we can use the same delegate implementation for upload and download.
+ private boolean isUploading;
+ protected final AuthHeaderProvider authHeaderProvider;
+
+ public MetaGlobal(String metaURL, AuthHeaderProvider authHeaderProvider) {
+ this.metaURL = metaURL;
+ this.authHeaderProvider = authHeaderProvider;
+ }
+
+ public void fetch(MetaGlobalDelegate delegate) {
+ this.callback = delegate;
+ try {
+ this.isUploading = false;
+ SyncStorageRecordRequest r = new SyncStorageRecordRequest(this.metaURL);
+ r.delegate = this;
+ r.get();
+ } catch (URISyntaxException e) {
+ this.callback.handleError(e);
+ }
+ }
+
+ public void upload(MetaGlobalDelegate callback) {
+ try {
+ this.isUploading = true;
+ SyncStorageRecordRequest r = new SyncStorageRecordRequest(this.metaURL);
+
+ r.delegate = this;
+ this.callback = callback;
+ r.put(this.asCryptoRecord());
+ } catch (Exception e) {
+ callback.handleError(e);
+ }
+ }
+
+ protected ExtendedJSONObject asRecordContents() {
+ ExtendedJSONObject json = new ExtendedJSONObject();
+ json.put("storageVersion", storageVersion);
+ json.put("engines", engines);
+ json.put("syncID", syncID);
+ json.put("declined", declined);
+ return json;
+ }
+
+ /**
+ * Return a copy ready for upload.
+ * @return an unencrypted <code>CryptoRecord</code>.
+ */
+ public CryptoRecord asCryptoRecord() {
+ ExtendedJSONObject payload = this.asRecordContents();
+ CryptoRecord record = new CryptoRecord(payload);
+ record.collection = "meta";
+ record.guid = "global";
+ record.deleted = false;
+ return record;
+ }
+
+ public void setFromRecord(CryptoRecord record) throws IllegalStateException, IOException, NonObjectJSONException, NonArrayJSONException {
+ if (record == null) {
+ throw new IllegalArgumentException("Cannot set meta/global from null record");
+ }
+ Logger.debug(LOG_TAG, "meta/global is " + record.payload.toJSONString());
+ this.storageVersion = (Long) record.payload.get("storageVersion");
+ this.syncID = (String) record.payload.get("syncID");
+
+ setEngines(record.payload.getObject("engines"));
+
+ // Accepts null -- declined can be missing.
+ setDeclinedEngineNames(record.payload.getArray("declined"));
+ }
+
+ public Long getStorageVersion() {
+ return this.storageVersion;
+ }
+
+ public void setStorageVersion(Long version) {
+ this.storageVersion = version;
+ }
+
+ public ExtendedJSONObject getEngines() {
+ return engines;
+ }
+
+ @SuppressWarnings("unchecked")
+ public void declineEngine(String engine) {
+ if (this.declined == null) {
+ JSONArray replacement = new JSONArray();
+ replacement.add(engine);
+ setDeclinedEngineNames(replacement);
+ return;
+ }
+
+ this.declined.add(engine);
+ }
+
+ @SuppressWarnings("unchecked")
+ public void declineEngineNames(Collection<String> additional) {
+ if (this.declined == null) {
+ JSONArray replacement = new JSONArray();
+ replacement.addAll(additional);
+ setDeclinedEngineNames(replacement);
+ return;
+ }
+
+ for (String engine : additional) {
+ if (!this.declined.contains(engine)) {
+ this.declined.add(engine);
+ }
+ }
+ }
+
+ public void setDeclinedEngineNames(JSONArray declined) {
+ if (declined == null) {
+ this.declined = new JSONArray();
+ return;
+ }
+ this.declined = declined;
+ }
+
+ /**
+ * Return the set of engines that we support (given as an argument)
+ * but the user hasn't explicitly declined on another device.
+ *
+ * Can return the input if the user hasn't declined any engines.
+ */
+ public Set<String> getNonDeclinedEngineNames(Set<String> supported) {
+ if (this.declined == null ||
+ this.declined.isEmpty()) {
+ return supported;
+ }
+
+ final Set<String> result = new HashSet<String>(supported);
+ result.removeAll(this.declined);
+ return result;
+ }
+
+ public void setEngines(ExtendedJSONObject engines) {
+ if (engines == null) {
+ engines = new ExtendedJSONObject();
+ }
+ this.engines = engines;
+ final int count = engines.size();
+ versions = new HashMap<String, Integer>(count);
+ syncIDs = new HashMap<String, String>(count);
+ exceptions = new HashMap<String, MetaGlobalException>(count);
+ for (String engineName : engines.keySet()) {
+ try {
+ ExtendedJSONObject engineEntry = engines.getObject(engineName);
+ recordEngineState(engineName, engineEntry);
+ } catch (NonObjectJSONException e) {
+ Logger.error(LOG_TAG, "Engine field for " + engineName + " in meta/global is not an object.");
+ recordEngineState(engineName, new ExtendedJSONObject()); // Doesn't have a version or syncID, for example, so will be server wiped.
+ }
+ }
+ }
+
+ /**
+ * Take a JSON object corresponding to the 'engines' field for the provided engine name,
+ * updating {@link #syncIDs} and {@link #versions} accordingly.
+ *
+ * If the record is malformed, an entry is added to {@link #exceptions}, to be rethrown
+ * during validation.
+ */
+ protected void recordEngineState(String engineName, ExtendedJSONObject engineEntry) {
+ if (engineEntry == null) {
+ throw new IllegalArgumentException("engineEntry cannot be null.");
+ }
+
+ // Record syncID first, so that engines with bad versions are recorded.
+ try {
+ String syncID = engineEntry.getString("syncID");
+ if (syncID == null) {
+ Logger.warn(LOG_TAG, "No syncID for " + engineName + ". Recording exception.");
+ exceptions.put(engineName, new MetaGlobalMalformedSyncIDException());
+ }
+ syncIDs.put(engineName, syncID);
+ } catch (ClassCastException e) {
+ // Malformed syncID on the server. Wipe the server.
+ Logger.warn(LOG_TAG, "Malformed syncID " + engineEntry.get("syncID") +
+ " for " + engineName + ". Recording exception.");
+ exceptions.put(engineName, new MetaGlobalMalformedSyncIDException());
+ }
+
+ try {
+ Integer version = engineEntry.getIntegerSafely("version");
+ Logger.trace(LOG_TAG, "Engine " + engineName + " has server version " + version);
+ if (version == null ||
+ version == 0) {
+ // Invalid version. Wipe the server.
+ Logger.warn(LOG_TAG, "Malformed version " + version +
+ " for " + engineName + ". Recording exception.");
+ exceptions.put(engineName, new MetaGlobalMalformedVersionException());
+ return;
+ }
+ versions.put(engineName, version);
+ } catch (NumberFormatException e) {
+ // Invalid version. Wipe the server.
+ Logger.warn(LOG_TAG, "Malformed version " + engineEntry.get("version") +
+ " for " + engineName + ". Recording exception.");
+ exceptions.put(engineName, new MetaGlobalMalformedVersionException());
+ return;
+ }
+ }
+
+ /**
+ * Get enabled engine names.
+ *
+ * @return a collection of engine names or <code>null</code> if meta/global
+ * was malformed.
+ */
+ public Set<String> getEnabledEngineNames() {
+ if (engines == null) {
+ return null;
+ }
+ return new HashSet<String>(engines.keySet());
+ }
+
+ @SuppressWarnings("unchecked")
+ public Set<String> getDeclinedEngineNames() {
+ if (declined == null) {
+ return null;
+ }
+ return new HashSet<String>(declined);
+ }
+
+ /**
+ * Returns if the server settings and local settings match.
+ * Throws a specific MetaGlobalException if that's not the case.
+ */
+ public void verifyEngineSettings(String engineName, EngineSettings engineSettings)
+ throws MetaGlobalException {
+
+ // We use syncIDs as our canary.
+ if (syncIDs == null) {
+ throw new IllegalStateException("No meta/global record yet processed.");
+ }
+
+ if (engineSettings == null) {
+ throw new IllegalArgumentException("engineSettings cannot be null.");
+ }
+
+ // First, see if we had a parsing problem.
+ final MetaGlobalException exception = exceptions.get(engineName);
+ if (exception != null) {
+ throw exception;
+ }
+
+ final String syncID = syncIDs.get(engineName);
+ if (syncID == null) {
+ // We have checked engineName against enabled engine names before this, so
+ // we should either have a syncID or an exception for this engine already.
+ throw new IllegalArgumentException("Unknown engine " + engineName);
+ }
+
+ // Since we don't have an exception, and we do have a syncID, we should have a version.
+ final Integer version = versions.get(engineName);
+ if (version > engineSettings.version) {
+ // We're out of date.
+ throw new MetaGlobalException.MetaGlobalStaleClientVersionException(version);
+ }
+
+ if (!syncID.equals(engineSettings.syncID)) {
+ // Our syncID is wrong. Reset client and take the server syncID.
+ throw new MetaGlobalException.MetaGlobalStaleClientSyncIDException(syncID);
+ }
+ }
+
+ public String getSyncID() {
+ return syncID;
+ }
+
+ public void setSyncID(String syncID) {
+ this.syncID = syncID;
+ }
+
+ // SyncStorageRequestDelegate methods for fetching.
+ public String credentials() {
+ return null;
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return authHeaderProvider;
+ }
+
+ @Override
+ public String ifUnmodifiedSince() {
+ return null;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ if (this.isUploading) {
+ this.handleUploadSuccess(response);
+ } else {
+ this.handleDownloadSuccess(response);
+ }
+ }
+
+ private void handleUploadSuccess(SyncStorageResponse response) {
+ this.callback.handleSuccess(this, response);
+ }
+
+ private void handleDownloadSuccess(SyncStorageResponse response) {
+ if (response.wasSuccessful()) {
+ try {
+ CryptoRecord record = CryptoRecord.fromJSONRecord(response.jsonObjectBody());
+ this.setFromRecord(record);
+ this.callback.handleSuccess(this, response);
+ } catch (Exception e) {
+ this.callback.handleError(e);
+ }
+ return;
+ }
+ this.callback.handleFailure(response);
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ if (response.getStatusCode() == 404) {
+ this.callback.handleMissing(this, response);
+ return;
+ }
+ this.callback.handleFailure(response);
+ }
+
+ @Override
+ public void handleRequestError(Exception e) {
+ this.callback.handleError(e);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalException.java
new file mode 100644
index 000000000..bec531d11
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalException.java
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+public class MetaGlobalException extends SyncException {
+ private static final long serialVersionUID = -6182315615113508925L;
+
+ public static class MetaGlobalMalformedSyncIDException extends MetaGlobalException {
+ private static final long serialVersionUID = 1L;
+ }
+
+ public static class MetaGlobalMalformedVersionException extends MetaGlobalException {
+ private static final long serialVersionUID = 1L;
+ }
+
+ public static class MetaGlobalOutdatedVersionException extends MetaGlobalException {
+ private static final long serialVersionUID = 1L;
+ }
+
+ public static class MetaGlobalStaleClientVersionException extends MetaGlobalException {
+ private static final long serialVersionUID = 1L;
+ public final int serverVersion;
+ public MetaGlobalStaleClientVersionException(final int version) {
+ this.serverVersion = version;
+ }
+ }
+
+ public static class MetaGlobalStaleClientSyncIDException extends MetaGlobalException {
+ private static final long serialVersionUID = 1L;
+ public final String serverSyncID;
+ public MetaGlobalStaleClientSyncIDException(final String syncID) {
+ this.serverSyncID = syncID;
+ }
+ }
+
+ public static class MetaGlobalEngineStateChangedException extends MetaGlobalException {
+ private static final long serialVersionUID = 1L;
+ public final boolean isEnabled;
+ public MetaGlobalEngineStateChangedException(boolean isEnabled) {
+ this.isEnabled = isEnabled;
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalMissingEnginesException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalMissingEnginesException.java
new file mode 100644
index 000000000..91bfd2f76
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalMissingEnginesException.java
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+public class MetaGlobalMissingEnginesException extends MetaGlobalException {
+ private static final long serialVersionUID = -2662107402622277865L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalNotSetException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalNotSetException.java
new file mode 100644
index 000000000..ef059c71d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/MetaGlobalNotSetException.java
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+public class MetaGlobalNotSetException extends MetaGlobalException {
+ private static final long serialVersionUID = 2959032409571832970L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NoCollectionKeysSetException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NoCollectionKeysSetException.java
new file mode 100644
index 000000000..323e355b4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NoCollectionKeysSetException.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.content.SyncResult;
+
+public class NoCollectionKeysSetException extends SyncException {
+ private static final long serialVersionUID = -6185128075412771120L;
+
+ @Override
+ public void updateStats(GlobalSession globalSession, SyncResult syncResult) {
+ syncResult.stats.numAuthExceptions++;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NodeAuthenticationException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NodeAuthenticationException.java
new file mode 100644
index 000000000..a5cd5f0eb
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NodeAuthenticationException.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.content.SyncResult;
+
+public class NodeAuthenticationException extends SyncException {
+ private static final long serialVersionUID = 8156745873212364352L;
+
+ @Override
+ public void updateStats(GlobalSession globalSession, SyncResult syncResult) {
+ syncResult.stats.numAuthExceptions++;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonArrayJSONException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonArrayJSONException.java
new file mode 100644
index 000000000..554645b11
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonArrayJSONException.java
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+public class NonArrayJSONException extends UnexpectedJSONException {
+ private static final long serialVersionUID = 5582918057432365749L;
+
+ public NonArrayJSONException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public NonArrayJSONException(Throwable throwable) {
+ super(throwable);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonObjectJSONException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonObjectJSONException.java
new file mode 100644
index 000000000..fd50d465e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NonObjectJSONException.java
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+public class NonObjectJSONException extends UnexpectedJSONException {
+ private static final long serialVersionUID = 2214238763035650087L;
+
+ public NonObjectJSONException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public NonObjectJSONException(Throwable throwable) {
+ super(throwable);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NullClusterURLException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NullClusterURLException.java
new file mode 100644
index 000000000..c1d8833b6
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/NullClusterURLException.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.content.SyncResult;
+
+public class NullClusterURLException extends SyncException {
+ private static final long serialVersionUID = 4277845518548393161L;
+
+ @Override
+ public void updateStats(GlobalSession globalSession, SyncResult syncResult) {
+ syncResult.stats.numAuthExceptions++;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PersistedMetaGlobal.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PersistedMetaGlobal.java
new file mode 100644
index 000000000..d3467545c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PersistedMetaGlobal.java
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+
+import android.content.SharedPreferences;
+
+public class PersistedMetaGlobal {
+ public static final String LOG_TAG = "PersistedMetaGlobal";
+
+ public static final String META_GLOBAL_SERVER_RESPONSE_BODY = "metaGlobalServerResponseBody";
+ public static final String META_GLOBAL_LAST_MODIFIED = "metaGlobalLastModified";
+
+ protected SharedPreferences prefs;
+
+ public PersistedMetaGlobal(SharedPreferences prefs) {
+ this.prefs = prefs;
+ }
+
+ /**
+ * Sets a <code>MetaGlobal</code> from persisted prefs.
+ *
+ * @param metaUrl
+ * meta/global server URL
+ * @param credentials
+ * Sync credentials
+ *
+ * @return <MetaGlobal> set from previously fetched meta/global record from
+ * server
+ */
+ public MetaGlobal metaGlobal(String metaUrl, AuthHeaderProvider authHeaderProvider) {
+ String json = prefs.getString(META_GLOBAL_SERVER_RESPONSE_BODY, null);
+ if (json == null) {
+ return null;
+ }
+ MetaGlobal metaGlobal = null;
+ try {
+ CryptoRecord cryptoRecord = CryptoRecord.fromJSONRecord(json);
+ MetaGlobal mg = new MetaGlobal(metaUrl, authHeaderProvider);
+ mg.setFromRecord(cryptoRecord);
+ metaGlobal = mg;
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception decrypting persisted meta/global.", e);
+ }
+ return metaGlobal;
+ }
+
+ public void persistMetaGlobal(MetaGlobal metaGlobal) {
+ if (metaGlobal == null) {
+ Logger.debug(LOG_TAG, "Clearing persisted meta/global.");
+ prefs.edit().remove(META_GLOBAL_SERVER_RESPONSE_BODY).commit();
+ return;
+ }
+ try {
+ CryptoRecord cryptoRecord = metaGlobal.asCryptoRecord();
+ String json = cryptoRecord.toJSONString();
+ Logger.debug(LOG_TAG, "Persisting meta/global.");
+ prefs.edit().putString(META_GLOBAL_SERVER_RESPONSE_BODY, json).commit();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception encrypting while persisting meta/global.", e);
+ }
+ }
+
+ public long lastModified() {
+ return prefs.getLong(META_GLOBAL_LAST_MODIFIED, -1);
+ }
+
+ public void persistLastModified(long lastModified) {
+ if (lastModified <= 0) {
+ Logger.debug(LOG_TAG, "Clearing persisted meta/global last modified timestamp.");
+ prefs.edit().remove(META_GLOBAL_LAST_MODIFIED).commit();
+ return;
+ }
+ Logger.debug(LOG_TAG, "Persisting meta/global last modified timestamp " + lastModified + ".");
+ prefs.edit().putLong(META_GLOBAL_LAST_MODIFIED, lastModified).commit();
+ }
+
+ public void purge() {
+ persistLastModified(-1);
+ persistMetaGlobal(null);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PrefsBackoffHandler.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PrefsBackoffHandler.java
new file mode 100644
index 000000000..63f6446da
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/PrefsBackoffHandler.java
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+
+public class PrefsBackoffHandler implements BackoffHandler {
+ public static final String PREF_EARLIEST_NEXT = "earliestnext";
+
+ private final SharedPreferences prefs;
+ private final String prefEarliest;
+
+ public PrefsBackoffHandler(final SharedPreferences prefs, final String prefSuffix) {
+ if (prefs == null) {
+ throw new IllegalArgumentException("prefs must not be null.");
+ }
+ this.prefs = prefs;
+ this.prefEarliest = PREF_EARLIEST_NEXT + "." + prefSuffix;
+ }
+
+ @Override
+ public synchronized long getEarliestNextRequest() {
+ return prefs.getLong(prefEarliest, 0);
+ }
+
+ @Override
+ public synchronized void setEarliestNextRequest(final long next) {
+ final Editor edit = prefs.edit();
+ edit.putLong(prefEarliest, next);
+ edit.commit();
+ }
+
+ @Override
+ public synchronized void extendEarliestNextRequest(final long next) {
+ if (prefs.getLong(prefEarliest, 0) >= next) {
+ return;
+ }
+ final Editor edit = prefs.edit();
+ edit.putLong(prefEarliest, next);
+ edit.commit();
+ }
+
+ /**
+ * Return the number of milliseconds until we're allowed to touch the server again,
+ * or 0 if now is fine.
+ */
+ @Override
+ public long delayMilliseconds() {
+ long earliestNextRequest = getEarliestNextRequest();
+ if (earliestNextRequest <= 0) {
+ return 0;
+ }
+ long now = System.currentTimeMillis();
+ return Math.max(0, earliestNextRequest - now);
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/README.txt b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/README.txt
new file mode 100644
index 000000000..cf4624ca4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/README.txt
@@ -0,0 +1 @@
+These files are managed in the android-sync repo. Do not modify directly, or your changes will be lost.
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11PreviousPostFailedException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11PreviousPostFailedException.java
new file mode 100644
index 000000000..4ea77f37c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11PreviousPostFailedException.java
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+/**
+ * A previous POST failed, so we won't send any more records this session.
+ */
+public class Server11PreviousPostFailedException extends SyncException {
+ private static final long serialVersionUID = -3582490631414624310L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11RecordPostFailedException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11RecordPostFailedException.java
new file mode 100644
index 000000000..d654d3116
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Server11RecordPostFailedException.java
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+/**
+ * The server rejected a record in its "failure" array.
+ */
+public class Server11RecordPostFailedException extends SyncException {
+ private static final long serialVersionUID = -8517471217486190314L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java
new file mode 100644
index 000000000..4c1584d5a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.FxAccountDeviceRegistrator;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
+import org.mozilla.gecko.util.HardwareUtils;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.content.SharedPreferences;
+
+/**
+ * A <code>ClientsDataDelegate</code> implementation that persists to a
+ * <code>SharedPreferences</code> instance.
+ */
+public class SharedPreferencesClientsDataDelegate implements ClientsDataDelegate {
+ protected final SharedPreferences sharedPreferences;
+ protected final Context context;
+
+ public SharedPreferencesClientsDataDelegate(SharedPreferences sharedPreferences, Context context) {
+ this.sharedPreferences = sharedPreferences;
+ this.context = context;
+
+ // It's safe to init this multiple times.
+ HardwareUtils.init(context);
+ }
+
+ @Override
+ public synchronized String getAccountGUID() {
+ String accountGUID = sharedPreferences.getString(SyncConfiguration.PREF_ACCOUNT_GUID, null);
+ if (accountGUID == null) {
+ accountGUID = Utils.generateGuid();
+ sharedPreferences.edit().putString(SyncConfiguration.PREF_ACCOUNT_GUID, accountGUID).commit();
+ }
+ return accountGUID;
+ }
+
+ private synchronized void saveClientNameToSharedPreferences(String clientName, long now) {
+ sharedPreferences
+ .edit()
+ .putString(SyncConfiguration.PREF_CLIENT_NAME, clientName)
+ .putLong(SyncConfiguration.PREF_CLIENT_DATA_TIMESTAMP, now)
+ .apply();
+ }
+
+ /**
+ * Set client name.
+ *
+ * @param clientName to change to.
+ */
+ @Override
+ public synchronized void setClientName(String clientName, long now) {
+ saveClientNameToSharedPreferences(clientName, now);
+
+ // Update the FxA device registration
+ final Account account = FirefoxAccounts.getFirefoxAccount(context);
+ if (account != null) {
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+ fxAccount.resetDeviceRegistrationVersion();
+ }
+ }
+
+ @Override
+ public String getDefaultClientName() {
+ return FxAccountUtils.defaultClientName(context);
+ }
+
+ @Override
+ public synchronized String getClientName() {
+ String clientName = sharedPreferences.getString(SyncConfiguration.PREF_CLIENT_NAME, null);
+ if (clientName == null) {
+ clientName = getDefaultClientName();
+ long now = System.currentTimeMillis();
+ saveClientNameToSharedPreferences(clientName, now); // Save locally only to avoid a recursion loop
+ }
+ return clientName;
+ }
+
+ @Override
+ public synchronized void setClientsCount(int clientsCount) {
+ sharedPreferences.edit().putLong(SyncConfiguration.PREF_NUM_CLIENTS, clientsCount).commit();
+ }
+
+ @Override
+ public boolean isLocalGUID(String guid) {
+ return getAccountGUID().equals(guid);
+ }
+
+ @Override
+ public synchronized int getClientsCount() {
+ return (int) sharedPreferences.getLong(SyncConfiguration.PREF_NUM_CLIENTS, 0);
+ }
+
+ @Override
+ public long getLastModifiedTimestamp() {
+ return sharedPreferences.getLong(SyncConfiguration.PREF_CLIENT_DATA_TIMESTAMP, 0);
+ }
+
+ @Override
+ public String getFormFactor() {
+ if (HardwareUtils.isLargeTablet()) {
+ return "largetablet";
+ }
+
+ if (HardwareUtils.isSmallTablet()) {
+ return "smalltablet";
+ }
+
+ if (HardwareUtils.isTelevision()) {
+ return "tv";
+ }
+
+ return "phone";
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Sync11Configuration.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Sync11Configuration.java
new file mode 100644
index 000000000..4b2280895
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Sync11Configuration.java
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import java.net.URI;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+
+/**
+ * Override SyncConfiguration to restore the old behavior of clusterURL --
+ * that is, a URL without the protocol version etc.
+ *
+ */
+public class Sync11Configuration extends SyncConfiguration {
+ private static final String LOG_TAG = "Sync11Configuration";
+ private static final String API_VERSION = "1.1";
+
+ public Sync11Configuration(String username,
+ AuthHeaderProvider authHeaderProvider,
+ SharedPreferences prefs) {
+ super(username, authHeaderProvider, prefs);
+ }
+
+ public Sync11Configuration(String username,
+ AuthHeaderProvider authHeaderProvider,
+ SharedPreferences prefs,
+ KeyBundle keyBundle) {
+ super(username, authHeaderProvider, prefs, keyBundle);
+ }
+
+ @Override
+ public String getAPIVersion() {
+ return API_VERSION;
+ }
+
+ @Override
+ public String storageURL() {
+ return clusterURL + API_VERSION + "/" + username + "/storage";
+ }
+
+ @Override
+ protected String infoBaseURL() {
+ return clusterURL + API_VERSION + "/" + username + "/info/";
+ }
+
+ protected void setAndPersistClusterURL(URI u, SharedPreferences prefs) {
+ boolean shouldPersist = (prefs != null) && (clusterURL == null);
+
+ Logger.trace(LOG_TAG, "Setting cluster URL to " + u.toASCIIString() +
+ (shouldPersist ? ". Persisting." : ". Not persisting."));
+ clusterURL = u;
+ if (shouldPersist) {
+ Editor edit = prefs.edit();
+ edit.putString(PREF_CLUSTER_URL, clusterURL.toASCIIString());
+ edit.commit();
+ }
+ }
+
+ protected void setClusterURL(URI u, SharedPreferences prefs) {
+ if (u == null) {
+ Logger.warn(LOG_TAG, "Refusing to set cluster URL to null.");
+ return;
+ }
+ URI uri = u.normalize();
+ if (uri.toASCIIString().endsWith("/")) {
+ setAndPersistClusterURL(u, prefs);
+ return;
+ }
+ setAndPersistClusterURL(uri.resolve("/"), prefs);
+ Logger.trace(LOG_TAG, "Set cluster URL to " + clusterURL.toASCIIString() + ", given input " + u.toASCIIString());
+ }
+
+ @Override
+ public void setClusterURL(URI u) {
+ setClusterURL(u, this.getPrefs());
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfiguration.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfiguration.java
new file mode 100644
index 000000000..53edf5f84
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfiguration.java
@@ -0,0 +1,480 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import org.mozilla.gecko.background.common.PrefsBranch;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.crypto.PersistedCrypto5Keys;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+
+public class SyncConfiguration {
+ private static final String LOG_TAG = "SyncConfiguration";
+
+ // These must be set in GlobalSession's constructor.
+ public URI clusterURL;
+ public KeyBundle syncKeyBundle;
+
+ public InfoConfiguration infoConfiguration;
+
+ public CollectionKeys collectionKeys;
+ public InfoCollections infoCollections;
+ public MetaGlobal metaGlobal;
+ public String syncID;
+
+ protected final String username;
+
+ /**
+ * Persisted collection of enabledEngineNames.
+ * <p>
+ * Can contain engines Android Sync is not currently aware of, such as "prefs"
+ * or "addons".
+ * <p>
+ * Copied from latest downloaded meta/global record and used to generate a
+ * fresh meta/global record for upload.
+ */
+ public Set<String> enabledEngineNames;
+ public Set<String> declinedEngineNames = new HashSet<String>();
+
+ /**
+ * Names of stages to sync <it>this sync</it>, or <code>null</code> to sync
+ * all known stages.
+ * <p>
+ * Generated <it>each sync</it> from extras bundle passed to
+ * <code>SyncAdapter.onPerformSync</code> and not persisted.
+ * <p>
+ * Not synchronized! Set this exactly once per global session and don't modify
+ * it -- especially not from multiple threads.
+ */
+ public Collection<String> stagesToSync;
+
+ /**
+ * Engines whose sync state has been modified by the user through
+ * SelectEnginesActivity, where each key-value pair is an engine name and
+ * its sync state.
+ *
+ * This differs from <code>enabledEngineNames</code> in that
+ * <code>enabledEngineNames</code> reflects the downloaded meta/global,
+ * whereas <code>userSelectedEngines</code> stores the differences in engines to
+ * sync that the user has selected.
+ *
+ * Each engine stage will check for engine changes at the beginning of the
+ * stage.
+ *
+ * If no engine sync state changes have been made by the user, userSelectedEngines
+ * will be null, and Sync will proceed normally.
+ *
+ * If the user has made changes to engine syncing state, each engine will sync
+ * according to the sync state specified in userSelectedEngines and propagate that
+ * state to meta/global, to be uploaded.
+ */
+ public Map<String, Boolean> userSelectedEngines;
+ public long userSelectedEnginesTimestamp;
+
+ public SharedPreferences prefs;
+
+ protected final AuthHeaderProvider authHeaderProvider;
+
+ public static final String PREF_PREFS_VERSION = "prefs.version";
+ public static final long CURRENT_PREFS_VERSION = 1;
+
+ public static final String CLIENTS_COLLECTION_TIMESTAMP = "serverClientsTimestamp"; // When the collection was touched.
+ public static final String CLIENT_RECORD_TIMESTAMP = "serverClientRecordTimestamp"; // When our record was touched.
+ public static final String MIGRATION_SENTINEL_CHECK_TIMESTAMP = "migrationSentinelCheckTimestamp"; // When we last looked in meta/fxa_credentials.
+
+ public static final String PREF_CLUSTER_URL = "clusterURL";
+ public static final String PREF_SYNC_ID = "syncID";
+
+ public static final String PREF_ENABLED_ENGINE_NAMES = "enabledEngineNames";
+ public static final String PREF_DECLINED_ENGINE_NAMES = "declinedEngineNames";
+ public static final String PREF_USER_SELECTED_ENGINES_TO_SYNC = "userSelectedEngines";
+ public static final String PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP = "userSelectedEnginesTimestamp";
+
+ public static final String PREF_CLUSTER_URL_IS_STALE = "clusterurlisstale";
+
+ public static final String PREF_ACCOUNT_GUID = "account.guid";
+ public static final String PREF_CLIENT_NAME = "account.clientName";
+ public static final String PREF_NUM_CLIENTS = "account.numClients";
+ public static final String PREF_CLIENT_DATA_TIMESTAMP = "account.clientDataTimestamp";
+
+ private static final String API_VERSION = "1.5";
+
+ public SyncConfiguration(String username, AuthHeaderProvider authHeaderProvider, SharedPreferences prefs) {
+ this.username = username;
+ this.authHeaderProvider = authHeaderProvider;
+ this.prefs = prefs;
+ this.loadFromPrefs(prefs);
+ }
+
+ public SyncConfiguration(String username, AuthHeaderProvider authHeaderProvider, SharedPreferences prefs, KeyBundle syncKeyBundle) {
+ this(username, authHeaderProvider, prefs);
+ this.syncKeyBundle = syncKeyBundle;
+ }
+
+ public String getAPIVersion() {
+ return API_VERSION;
+ }
+
+ public SharedPreferences getPrefs() {
+ return this.prefs;
+ }
+
+ /**
+ * Valid engines supported by Android Sync.
+ *
+ * @return Set<String> of valid engine names that Android Sync implements.
+ */
+ public static Set<String> validEngineNames() {
+ Set<String> engineNames = new HashSet<String>();
+ for (Stage stage : Stage.getNamedStages()) {
+ engineNames.add(stage.getRepositoryName());
+ }
+ return engineNames;
+ }
+
+ /**
+ * Return a convenient accessor for part of prefs.
+ * @return
+ * A PrefsBranch object representing this
+ * section of the preferences space.
+ */
+ public PrefsBranch getBranch(String prefix) {
+ return new PrefsBranch(this.getPrefs(), prefix);
+ }
+
+ /**
+ * Gets the engine names that are enabled, declined, or other (depending on pref) in meta/global.
+ *
+ * @param prefs
+ * SharedPreferences that the engines are associated with.
+ * @param pref
+ * The preference name to use. E.g, PREF_ENABLED_ENGINE_NAMES.
+ * @return Set<String> of the enabled engine names if they have been stored,
+ * or null otherwise.
+ */
+ protected static Set<String> getEngineNamesFromPref(SharedPreferences prefs, String pref) {
+ final String json = prefs.getString(pref, null);
+ if (json == null) {
+ return null;
+ }
+ try {
+ final ExtendedJSONObject o = new ExtendedJSONObject(json);
+ return new HashSet<String>(o.keySet());
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * Returns the set of engine names that the user has enabled. If none
+ * have been stored in prefs, <code>null</code> is returned.
+ */
+ public static Set<String> getEnabledEngineNames(SharedPreferences prefs) {
+ return getEngineNamesFromPref(prefs, PREF_ENABLED_ENGINE_NAMES);
+ }
+
+ /**
+ * Returns the set of engine names that the user has declined.
+ */
+ public static Set<String> getDeclinedEngineNames(SharedPreferences prefs) {
+ final Set<String> names = getEngineNamesFromPref(prefs, PREF_DECLINED_ENGINE_NAMES);
+ if (names == null) {
+ return new HashSet<String>();
+ }
+ return names;
+ }
+
+ /**
+ * Gets the engines whose sync states have been changed by the user through the
+ * SelectEnginesActivity.
+ *
+ * @param prefs
+ * SharedPreferences of account that the engines are associated with.
+ * @return Map<String, Boolean> of changed engines. Key is the lower-cased
+ * engine name, Value is the new sync state.
+ */
+ public static Map<String, Boolean> getUserSelectedEngines(SharedPreferences prefs) {
+ String json = prefs.getString(PREF_USER_SELECTED_ENGINES_TO_SYNC, null);
+ if (json == null) {
+ return null;
+ }
+ try {
+ ExtendedJSONObject o = new ExtendedJSONObject(json);
+ Map<String, Boolean> map = new HashMap<String, Boolean>();
+ for (Entry<String, Object> e : o.entrySet()) {
+ String key = e.getKey();
+ Boolean value = (Boolean) e.getValue();
+ map.put(key, value);
+ // Forms depends on history. Add forms if history is selected.
+ if ("history".equals(key)) {
+ map.put("forms", value);
+ }
+ }
+ // Sanity check: remove forms if history does not exist.
+ if (!map.containsKey("history")) {
+ map.remove("forms");
+ }
+ return map;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * Store a Map of engines and their sync states to prefs.
+ *
+ * Any engine that's disabled in the input is also recorded
+ * as a declined engine, overwriting the stored values.
+ *
+ * @param prefs
+ * SharedPreferences that the engines are associated with.
+ * @param selectedEngines
+ * Map<String, Boolean> of engine name to sync state
+ */
+ public static void storeSelectedEnginesToPrefs(SharedPreferences prefs, Map<String, Boolean> selectedEngines) {
+ ExtendedJSONObject jObj = new ExtendedJSONObject();
+ HashSet<String> declined = new HashSet<String>();
+ for (Entry<String, Boolean> e : selectedEngines.entrySet()) {
+ final Boolean enabled = e.getValue();
+ final String engine = e.getKey();
+ jObj.put(engine, enabled);
+ if (!enabled) {
+ declined.add(engine);
+ }
+ }
+
+ // Our history checkbox drives form history, too.
+ // We don't need to do this for enablement: that's done at retrieval time.
+ if (selectedEngines.containsKey("history") && !selectedEngines.get("history")) {
+ declined.add("forms");
+ }
+
+ String json = jObj.toJSONString();
+ long currentTime = System.currentTimeMillis();
+ Editor edit = prefs.edit();
+ edit.putString(PREF_USER_SELECTED_ENGINES_TO_SYNC, json);
+ edit.putString(PREF_DECLINED_ENGINE_NAMES, setToJSONObjectString(declined));
+ edit.putLong(PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP, currentTime);
+ Logger.error(LOG_TAG, "Storing user-selected engines at [" + currentTime + "].");
+ edit.commit();
+ }
+
+ public void loadFromPrefs(SharedPreferences prefs) {
+ if (prefs.contains(PREF_CLUSTER_URL)) {
+ String u = prefs.getString(PREF_CLUSTER_URL, null);
+ try {
+ clusterURL = new URI(u);
+ Logger.trace(LOG_TAG, "Set clusterURL from bundle: " + u);
+ } catch (URISyntaxException e) {
+ Logger.warn(LOG_TAG, "Ignoring bundle clusterURL (" + u + "): invalid URI.", e);
+ }
+ }
+ if (prefs.contains(PREF_SYNC_ID)) {
+ syncID = prefs.getString(PREF_SYNC_ID, null);
+ Logger.trace(LOG_TAG, "Set syncID from bundle: " + syncID);
+ }
+ enabledEngineNames = getEnabledEngineNames(prefs);
+ declinedEngineNames = getDeclinedEngineNames(prefs);
+ userSelectedEngines = getUserSelectedEngines(prefs);
+ userSelectedEnginesTimestamp = prefs.getLong(PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP, 0);
+ // We don't set crypto/keys here because we need the syncKeyBundle to decrypt the JSON
+ // and we won't have it on construction.
+ // TODO: MetaGlobal, password, infoCollections.
+ }
+
+ public void persistToPrefs() {
+ this.persistToPrefs(this.getPrefs());
+ }
+
+ private static String setToJSONObjectString(Set<String> set) {
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ for (String name : set) {
+ o.put(name, 0);
+ }
+ return o.toJSONString();
+ }
+
+ public void persistToPrefs(SharedPreferences prefs) {
+ Editor edit = prefs.edit();
+ if (clusterURL == null) {
+ edit.remove(PREF_CLUSTER_URL);
+ } else {
+ edit.putString(PREF_CLUSTER_URL, clusterURL.toASCIIString());
+ }
+ if (syncID != null) {
+ edit.putString(PREF_SYNC_ID, syncID);
+ }
+ if (enabledEngineNames == null) {
+ edit.remove(PREF_ENABLED_ENGINE_NAMES);
+ } else {
+ edit.putString(PREF_ENABLED_ENGINE_NAMES, setToJSONObjectString(enabledEngineNames));
+ }
+ if (declinedEngineNames == null || declinedEngineNames.isEmpty()) {
+ edit.remove(PREF_DECLINED_ENGINE_NAMES);
+ } else {
+ edit.putString(PREF_DECLINED_ENGINE_NAMES, setToJSONObjectString(declinedEngineNames));
+ }
+ if (userSelectedEngines == null) {
+ edit.remove(PREF_USER_SELECTED_ENGINES_TO_SYNC);
+ edit.remove(PREF_USER_SELECTED_ENGINES_TO_SYNC_TIMESTAMP);
+ }
+ // Don't bother saving userSelectedEngines - these should only be changed by
+ // SelectEnginesActivity.
+ edit.commit();
+ // TODO: keys.
+ }
+
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return authHeaderProvider;
+ }
+
+ public CollectionKeys getCollectionKeys() {
+ return collectionKeys;
+ }
+
+ public void setCollectionKeys(CollectionKeys k) {
+ collectionKeys = k;
+ }
+
+ /**
+ * Return path to storage endpoint without trailing slash.
+ *
+ * @return storage endpoint without trailing slash.
+ */
+ public String storageURL() {
+ return clusterURL + "/storage";
+ }
+
+ protected String infoBaseURL() {
+ return clusterURL + "/info/";
+ }
+
+ public String infoCollectionsURL() {
+ return infoBaseURL() + "collections";
+ }
+
+ public String infoConfigurationURL() {
+ return infoBaseURL() + "configuration";
+ }
+
+ public String infoCollectionCountsURL() {
+ return infoBaseURL() + "collection_counts";
+ }
+
+ public String metaURL() {
+ return storageURL() + "/meta/global";
+ }
+
+ public URI collectionURI(String collection) throws URISyntaxException {
+ return new URI(storageURL() + "/" + collection);
+ }
+
+ public URI collectionURI(String collection, boolean full) throws URISyntaxException {
+ // Do it this way to make it easier to add more params later.
+ // It's pretty ugly, I'll grant.
+ boolean anyParams = full;
+ String uriParams = "";
+ if (anyParams) {
+ StringBuilder params = new StringBuilder("?");
+ if (full) {
+ params.append("full=1");
+ }
+ uriParams = params.toString();
+ }
+ String uri = storageURL() + "/" + collection + uriParams;
+ return new URI(uri);
+ }
+
+ public URI wboURI(String collection, String id) throws URISyntaxException {
+ return new URI(storageURL() + "/" + collection + "/" + id);
+ }
+
+ public URI keysURI() throws URISyntaxException {
+ return wboURI("crypto", "keys");
+ }
+
+ public URI getClusterURL() {
+ return clusterURL;
+ }
+
+ public String getClusterURLString() {
+ if (clusterURL == null) {
+ return null;
+ }
+ return clusterURL.toASCIIString();
+ }
+
+ public void setClusterURL(URI u) {
+ this.clusterURL = u;
+ }
+
+ /**
+ * Used for direct management of related prefs.
+ */
+ public Editor getEditor() {
+ return this.getPrefs().edit();
+ }
+
+ /**
+ * We persist two different clients timestamps: our own record's,
+ * and the timestamp for the collection.
+ */
+ public void persistServerClientRecordTimestamp(long timestamp) {
+ getEditor().putLong(SyncConfiguration.CLIENT_RECORD_TIMESTAMP, timestamp).commit();
+ }
+
+ public long getPersistedServerClientRecordTimestamp() {
+ return getPrefs().getLong(SyncConfiguration.CLIENT_RECORD_TIMESTAMP, 0L);
+ }
+
+ public void persistServerClientsTimestamp(long timestamp) {
+ getEditor().putLong(SyncConfiguration.CLIENTS_COLLECTION_TIMESTAMP, timestamp).commit();
+ }
+
+ public long getPersistedServerClientsTimestamp() {
+ return getPrefs().getLong(SyncConfiguration.CLIENTS_COLLECTION_TIMESTAMP, 0L);
+ }
+
+ public void persistLastMigrationSentinelCheckTimestamp(long timestamp) {
+ getEditor().putLong(SyncConfiguration.MIGRATION_SENTINEL_CHECK_TIMESTAMP, timestamp).commit();
+ }
+
+ public long getLastMigrationSentinelCheckTimestamp() {
+ return getPrefs().getLong(SyncConfiguration.MIGRATION_SENTINEL_CHECK_TIMESTAMP, 0L);
+ }
+
+ public void purgeCryptoKeys() {
+ if (collectionKeys != null) {
+ collectionKeys.clear();
+ }
+ persistedCryptoKeys().purge();
+ }
+
+ public void purgeMetaGlobal() {
+ metaGlobal = null;
+ persistedMetaGlobal().purge();
+ }
+
+ public PersistedCrypto5Keys persistedCryptoKeys() {
+ return new PersistedCrypto5Keys(getPrefs(), syncKeyBundle);
+ }
+
+ public PersistedMetaGlobal persistedMetaGlobal() {
+ return new PersistedMetaGlobal(getPrefs());
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfigurationException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfigurationException.java
new file mode 100644
index 000000000..02ba118c5
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConfigurationException.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.content.SyncResult;
+
+public class SyncConfigurationException extends SyncException {
+ private static final long serialVersionUID = 1107080177269358381L;
+
+ @Override
+ public void updateStats(GlobalSession globalSession, SyncResult syncResult) {
+ syncResult.stats.numAuthExceptions++;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConstants.java
new file mode 100644
index 000000000..5dc7b289f
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncConstants.java
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import org.mozilla.gecko.AppConstants;
+
+public class SyncConstants {
+ public static final String GLOBAL_LOG_TAG = "FxSync";
+ public static final String SYNC_MAJOR_VERSION = "1";
+ public static final String SYNC_MINOR_VERSION = "0";
+ public static final String SYNC_VERSION_STRING = SYNC_MAJOR_VERSION + "." +
+ AppConstants.MOZ_APP_VERSION + "." +
+ SYNC_MINOR_VERSION;
+
+ public static final String USER_AGENT = "Firefox AndroidSync " +
+ SYNC_VERSION_STRING + " (" +
+ AppConstants.MOZ_APP_UA_NAME + ")";
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncException.java
new file mode 100644
index 000000000..ee0902568
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SyncException.java
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.content.SyncResult;
+
+public abstract class SyncException extends Exception {
+ private static final long serialVersionUID = -6928990004393234738L;
+
+ public SyncException() {
+ super();
+ }
+
+ public SyncException(final Throwable e) {
+ super(e);
+ }
+
+ /**
+ * Update sync result statistics with information particular to this
+ * exception.
+ *
+ * @param globalSession
+ * current session, or null.
+ * @param syncResult
+ * Android sync result to update.
+ */
+ public void updateStats(GlobalSession globalSession, SyncResult syncResult) {
+ // Assume storage error.
+ // TODO: this logic is overly simplistic.
+ syncResult.databaseError = true;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SynchronizerConfiguration.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SynchronizerConfiguration.java
new file mode 100644
index 000000000..2b08be9c4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SynchronizerConfiguration.java
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.content.SharedPreferences.Editor;
+
+import org.mozilla.gecko.background.common.PrefsBranch;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+
+import java.io.IOException;
+
+public class SynchronizerConfiguration {
+ private static final String LOG_TAG = "SynczrConfiguration";
+
+ public String syncID;
+ public RepositorySessionBundle remoteBundle;
+ public RepositorySessionBundle localBundle;
+
+ public SynchronizerConfiguration(PrefsBranch config) throws NonObjectJSONException, IOException {
+ this.load(config);
+ }
+
+ public SynchronizerConfiguration(String syncID, RepositorySessionBundle remoteBundle, RepositorySessionBundle localBundle) {
+ this.syncID = syncID;
+ this.remoteBundle = remoteBundle;
+ this.localBundle = localBundle;
+ }
+
+ // This should get partly shuffled back into SyncConfiguration, I think.
+ public void load(PrefsBranch config) throws NonObjectJSONException, IOException {
+ if (config == null) {
+ throw new IllegalArgumentException("config cannot be null.");
+ }
+ String remoteJSON = config.getString("remote", null);
+ String localJSON = config.getString("local", null);
+ RepositorySessionBundle rB = new RepositorySessionBundle(remoteJSON);
+ RepositorySessionBundle lB = new RepositorySessionBundle(localJSON);
+ if (remoteJSON == null) {
+ rB.setTimestamp(0);
+ }
+ if (localJSON == null) {
+ lB.setTimestamp(0);
+ }
+ syncID = config.getString("syncID", null);
+ remoteBundle = rB;
+ localBundle = lB;
+ Logger.debug(LOG_TAG, "Loaded SynchronizerConfiguration. syncID: " + syncID + ", remoteBundle: " + remoteBundle + ", localBundle: " + localBundle);
+ }
+
+ public void persist(PrefsBranch config) {
+ if (config == null) {
+ throw new IllegalArgumentException("config cannot be null.");
+ }
+ String jsonRemote = remoteBundle.toJSONString();
+ String jsonLocal = localBundle.toJSONString();
+ Editor editor = config.edit();
+ editor.putString("remote", jsonRemote);
+ editor.putString("local", jsonLocal);
+ editor.putString("syncID", syncID);
+
+ // Synchronous.
+ editor.commit();
+ Logger.debug(LOG_TAG, "Persisted SynchronizerConfiguration. syncID: " + syncID + ", remoteBundle: " + remoteBundle + ", localBundle: " + localBundle);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ThreadPool.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ThreadPool.java
new file mode 100644
index 000000000..7f2029566
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/ThreadPool.java
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class ThreadPool {
+ public static ExecutorService executorService = Executors.newCachedThreadPool();
+ public static void run(Runnable runnable) {
+ executorService.submit(runnable);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnexpectedJSONException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnexpectedJSONException.java
new file mode 100644
index 000000000..e5771452c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnexpectedJSONException.java
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+public class UnexpectedJSONException extends Exception {
+ private static final long serialVersionUID = 4797570033096443169L;
+
+ public UnexpectedJSONException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public UnexpectedJSONException(Throwable throwable) {
+ super(throwable);
+ }
+
+ public static class BadRequiredFieldJSONException extends UnexpectedJSONException {
+ private static final long serialVersionUID = -9207736984784497612L;
+
+ public BadRequiredFieldJSONException(String string) {
+ super(string);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnknownSynchronizerConfigurationVersionException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnknownSynchronizerConfigurationVersionException.java
new file mode 100644
index 000000000..e2350095e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/UnknownSynchronizerConfigurationVersionException.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+public class UnknownSynchronizerConfigurationVersionException extends
+ SyncConfigurationException {
+ public int badVersion;
+ private static final long serialVersionUID = -8497255862099517395L;
+
+ public UnknownSynchronizerConfigurationVersionException(int version) {
+ super();
+ badVersion = version;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Utils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Utils.java
new file mode 100644
index 000000000..ef8859b4a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/Utils.java
@@ -0,0 +1,575 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import java.io.BufferedReader;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.UnsupportedEncodingException;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.net.URLDecoder;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.Executor;
+
+import org.json.simple.JSONArray;
+import org.mozilla.apache.commons.codec.binary.Base32;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.nativecode.NativeCrypto;
+import org.mozilla.gecko.sync.setup.Constants;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+
+public class Utils {
+
+ private static final String LOG_TAG = "Utils";
+
+ private static final SecureRandom sharedSecureRandom = new SecureRandom();
+
+ // See <http://developer.android.com/reference/android/content/Context.html#getSharedPreferences%28java.lang.String,%20int%29>
+ public static final int SHARED_PREFERENCES_MODE = 0;
+
+ public static String generateGuid() {
+ byte[] encodedBytes = Base64.encodeBase64(generateRandomBytes(9), false);
+ return new String(encodedBytes).replace("+", "-").replace("/", "_");
+ }
+
+ /**
+ * Helper to generate secure random bytes.
+ *
+ * @param length
+ * Number of bytes to generate.
+ */
+ public static byte[] generateRandomBytes(int length) {
+ byte[] bytes = new byte[length];
+ sharedSecureRandom.nextBytes(bytes);
+ return bytes;
+ }
+
+ /**
+ * Helper to generate a random integer in a specified range.
+ *
+ * @param r
+ * Generate an integer between 0 and r-1 inclusive.
+ */
+ public static BigInteger generateBigIntegerLessThan(BigInteger r) {
+ int maxBytes = (int) Math.ceil(((double) r.bitLength()) / 8);
+ BigInteger randInt = new BigInteger(generateRandomBytes(maxBytes));
+ return randInt.mod(r);
+ }
+
+ /**
+ * Helper to convert a byte array to a hex-encoded string
+ */
+ public static String byte2Hex(final byte[] b) {
+ return byte2Hex(b, 2 * b.length);
+ }
+
+ public static String byte2Hex(final byte[] b, int hexLength) {
+ final StringBuilder hs = new StringBuilder(Math.max(2*b.length, hexLength));
+ String stmp;
+
+ for (int n = 0; n < hexLength - 2*b.length; n++) {
+ hs.append("0");
+ }
+
+ for (int n = 0; n < b.length; n++) {
+ stmp = Integer.toHexString(b[n] & 0XFF);
+
+ if (stmp.length() == 1) {
+ hs.append("0");
+ }
+ hs.append(stmp);
+ }
+
+ return hs.toString();
+ }
+
+ public static byte[] concatAll(byte[] first, byte[]... rest) {
+ int totalLength = first.length;
+ for (byte[] array : rest) {
+ totalLength += array.length;
+ }
+
+ byte[] result = new byte[totalLength];
+ int offset = first.length;
+
+ System.arraycopy(first, 0, result, 0, offset);
+
+ for (byte[] array : rest) {
+ System.arraycopy(array, 0, result, offset, array.length);
+ offset += array.length;
+ }
+ return result;
+ }
+
+ /**
+ * Utility for Base64 decoding. Should ensure that the correct
+ * Apache Commons version is used.
+ *
+ * @param base64
+ * An input string. Will be decoded as UTF-8.
+ * @return
+ * A byte array of decoded values.
+ * @throws UnsupportedEncodingException
+ * Should not occur.
+ */
+ public static byte[] decodeBase64(String base64) throws UnsupportedEncodingException {
+ return Base64.decodeBase64(base64.getBytes("UTF-8"));
+ }
+
+ public static byte[] decodeFriendlyBase32(String base32) {
+ Base32 converter = new Base32();
+ final String translated = base32.replace('8', 'l').replace('9', 'o');
+ return converter.decode(translated.toUpperCase(Locale.US));
+ }
+
+ public static byte[] hex2Byte(String str, int byteLength) {
+ byte[] second = hex2Byte(str);
+ if (second.length >= byteLength) {
+ return second;
+ }
+ // New Java arrays are zeroed:
+ // http://docs.oracle.com/javase/specs/jls/se7/html/jls-4.html#jls-4.12.5
+ byte[] first = new byte[byteLength - second.length];
+ return Utils.concatAll(first, second);
+ }
+
+ public static byte[] hex2Byte(String str) {
+ if (str.length() % 2 == 1) {
+ str = "0" + str;
+ }
+
+ byte[] bytes = new byte[str.length() / 2];
+ for (int i = 0; i < bytes.length; i++) {
+ bytes[i] = (byte) Integer.parseInt(str.substring(2 * i, 2 * i + 2), 16);
+ }
+ return bytes;
+ }
+
+ public static String millisecondsToDecimalSecondsString(long ms) {
+ return millisecondsToDecimalSeconds(ms).toString();
+ }
+
+ // For dumping into JSON without quotes.
+ public static BigDecimal millisecondsToDecimalSeconds(long ms) {
+ return new BigDecimal(ms).movePointLeft(3);
+ }
+
+ // This lives until Bug 708956 lands, and we don't have to do it any more.
+ public static long decimalSecondsToMilliseconds(String decimal) {
+ try {
+ return new BigDecimal(decimal).movePointRight(3).longValue();
+ } catch (Exception e) {
+ return -1;
+ }
+ }
+
+ // Oh, Java.
+ public static long decimalSecondsToMilliseconds(Double decimal) {
+ // Truncates towards 0.
+ return (long)(decimal * 1000);
+ }
+
+ public static long decimalSecondsToMilliseconds(Long decimal) {
+ return decimal * 1000;
+ }
+
+ public static long decimalSecondsToMilliseconds(Integer decimal) {
+ return (decimal * 1000);
+ }
+
+ public static byte[] sha256(byte[] in)
+ throws NoSuchAlgorithmException {
+ MessageDigest sha1 = MessageDigest.getInstance("SHA-256");
+ return sha1.digest(in);
+ }
+
+ protected static byte[] sha1(final String utf8)
+ throws NoSuchAlgorithmException, UnsupportedEncodingException {
+ final byte[] bytes = utf8.getBytes("UTF-8");
+ try {
+ return NativeCrypto.sha1(bytes);
+ } catch (final LinkageError e) {
+ // This will throw UnsatisifiedLinkError (missing mozglue) the first time it is called, and
+ // ClassNotDefFoundError, for the uninitialized NativeCrypto class, each subsequent time this
+ // is called; LinkageError is their common ancestor.
+ Logger.warn(LOG_TAG, "Got throwable stretching password using native sha1 implementation; " +
+ "ignoring and using Java implementation.", e);
+ final MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
+ return sha1.digest(utf8.getBytes("UTF-8"));
+ }
+ }
+
+ protected static String sha1Base32(final String utf8)
+ throws NoSuchAlgorithmException, UnsupportedEncodingException {
+ return new Base32().encodeAsString(sha1(utf8)).toLowerCase(Locale.US);
+ }
+
+ /**
+ * If we encounter characters not allowed by the API (as found for
+ * instance in an email address), hash the value.
+ * @param account
+ * An account string.
+ * @return
+ * An acceptable string.
+ * @throws UnsupportedEncodingException
+ * @throws NoSuchAlgorithmException
+ */
+ public static String usernameFromAccount(final String account) throws NoSuchAlgorithmException, UnsupportedEncodingException {
+ if (account == null || account.equals("")) {
+ throw new IllegalArgumentException("No account name provided.");
+ }
+ if (account.matches("^[A-Za-z0-9._-]+$")) {
+ return account.toLowerCase(Locale.US);
+ }
+ return sha1Base32(account.toLowerCase(Locale.US));
+ }
+
+ public static SharedPreferences getSharedPreferences(final Context context, final String product, final String username, final String serverURL, final String profile, final long version)
+ throws NoSuchAlgorithmException, UnsupportedEncodingException {
+ String prefsPath = getPrefsPath(product, username, serverURL, profile, version);
+ return context.getSharedPreferences(prefsPath, SHARED_PREFERENCES_MODE);
+ }
+
+ /**
+ * Get shared preferences path for a Sync account.
+ *
+ * @param product the Firefox Sync product package name (like "org.mozilla.firefox").
+ * @param username the Sync account name, optionally encoded with <code>Utils.usernameFromAccount</code>.
+ * @param serverURL the Sync account server URL.
+ * @param profile the Firefox profile name.
+ * @param version the version of preferences to reference.
+ * @return the path.
+ * @throws NoSuchAlgorithmException
+ * @throws UnsupportedEncodingException
+ */
+ public static String getPrefsPath(final String product, final String username, final String serverURL, final String profile, final long version)
+ throws NoSuchAlgorithmException, UnsupportedEncodingException {
+ final String encodedAccount = sha1Base32(serverURL + ":" + usernameFromAccount(username));
+
+ if (version <= 0) {
+ return "sync.prefs." + encodedAccount;
+ } else {
+ final String sanitizedProduct = product.replace('.', '!').replace(' ', '!');
+ return "sync.prefs." + sanitizedProduct + "." + encodedAccount + "." + profile + "." + version;
+ }
+ }
+
+ public static void addToIndexBucketMap(TreeMap<Long, ArrayList<String>> map, long index, String value) {
+ ArrayList<String> bucket = map.get(index);
+ if (bucket == null) {
+ bucket = new ArrayList<String>();
+ }
+ bucket.add(value);
+ map.put(index, bucket);
+ }
+
+ /**
+ * Yes, an equality method that's null-safe.
+ */
+ private static boolean same(Object a, Object b) {
+ if (a == b) {
+ return true;
+ }
+ if (a == null || b == null) {
+ return false; // If both null, case above applies.
+ }
+ return a.equals(b);
+ }
+
+ /**
+ * Return true if the two arrays are both null, or are both arrays
+ * containing the same elements in the same order.
+ */
+ public static boolean sameArrays(JSONArray a, JSONArray b) {
+ if (a == b) {
+ return true;
+ }
+ if (a == null || b == null) {
+ return false;
+ }
+ final int size = a.size();
+ if (size != b.size()) {
+ return false;
+ }
+ for (int i = 0; i < size; ++i) {
+ if (!same(a.get(i), b.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Takes a URI, extracting URI components.
+ * @param scheme the URI scheme on which to match.
+ */
+ @SuppressWarnings("deprecation")
+ public static Map<String, String> extractURIComponents(String scheme, String uri) {
+ if (uri.indexOf(scheme) != 0) {
+ throw new IllegalArgumentException("URI scheme does not match: " + scheme);
+ }
+
+ // Do this the hard way to avoid taking a large dependency on
+ // HttpClient or getting all regex-tastic.
+ String components = uri.substring(scheme.length());
+ HashMap<String, String> out = new HashMap<String, String>();
+ String[] parts = components.split("&");
+ for (int i = 0; i < parts.length; ++i) {
+ String part = parts[i];
+ if (part.length() == 0) {
+ continue;
+ }
+ String[] pair = part.split("=", 2);
+ switch (pair.length) {
+ case 0:
+ continue;
+ case 1:
+ out.put(URLDecoder.decode(pair[0]), null);
+ break;
+ case 2:
+ out.put(URLDecoder.decode(pair[0]), URLDecoder.decode(pair[1]));
+ break;
+ }
+ }
+ return out;
+ }
+
+ // Because TextUtils.join is not stubbed.
+ public static String toDelimitedString(String delimiter, Collection<? extends Object> items) {
+ if (items == null || items.size() == 0) {
+ return "";
+ }
+
+ StringBuilder sb = new StringBuilder();
+ int i = 0;
+ int c = items.size();
+ for (Object object : items) {
+ sb.append(object.toString());
+ if (++i < c) {
+ sb.append(delimiter);
+ }
+ }
+ return sb.toString();
+ }
+
+ public static String toCommaSeparatedString(Collection<? extends Object> items) {
+ return toDelimitedString(", ", items);
+ }
+
+ /**
+ * Names of stages to sync: (ALL intersect SYNC) intersect (ALL minus SKIP).
+ *
+ * @param knownStageNames collection of known stage names (set ALL above).
+ * @param toSync set SYNC above, or <code>null</code> to sync all known stages.
+ * @param toSkip set SKIP above, or <code>null</code> to not skip any stages.
+ * @return stage names.
+ */
+ public static Collection<String> getStagesToSync(final Collection<String> knownStageNames, Collection<String> toSync, Collection<String> toSkip) {
+ if (toSkip == null) {
+ toSkip = new HashSet<String>();
+ } else {
+ toSkip = new HashSet<String>(toSkip);
+ }
+
+ if (toSync == null) {
+ toSync = new HashSet<String>(knownStageNames);
+ } else {
+ toSync = new HashSet<String>(toSync);
+ }
+ toSync.retainAll(knownStageNames);
+ toSync.removeAll(toSkip);
+ return toSync;
+ }
+
+ /**
+ * Get names of stages to sync: (ALL intersect SYNC) intersect (ALL minus SKIP).
+ *
+ * @param knownStageNames collection of known stage names (set ALL above).
+ * @param extras
+ * a <code>Bundle</code> instance (possibly null) optionally containing keys
+ * <code>EXTRAS_KEY_STAGES_TO_SYNC</code> (set SYNC above) and
+ * <code>EXTRAS_KEY_STAGES_TO_SKIP</code> (set SKIP above).
+ * @return stage names.
+ */
+ public static Collection<String> getStagesToSyncFromBundle(final Collection<String> knownStageNames, final Bundle extras) {
+ if (extras == null) {
+ return knownStageNames;
+ }
+ String toSyncString = extras.getString(Constants.EXTRAS_KEY_STAGES_TO_SYNC);
+ String toSkipString = extras.getString(Constants.EXTRAS_KEY_STAGES_TO_SKIP);
+ if (toSyncString == null && toSkipString == null) {
+ return knownStageNames;
+ }
+
+ ArrayList<String> toSync = null;
+ ArrayList<String> toSkip = null;
+ if (toSyncString != null) {
+ try {
+ toSync = new ArrayList<String>(new ExtendedJSONObject(toSyncString).keySet());
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception parsing stages to sync: '" + toSyncString + "'.", e);
+ }
+ }
+ if (toSkipString != null) {
+ try {
+ toSkip = new ArrayList<String>(new ExtendedJSONObject(toSkipString).keySet());
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception parsing stages to skip: '" + toSkipString + "'.", e);
+ }
+ }
+
+ Logger.info(LOG_TAG, "Asked to sync '" + Utils.toCommaSeparatedString(toSync) +
+ "' and to skip '" + Utils.toCommaSeparatedString(toSkip) + "'.");
+ return getStagesToSync(knownStageNames, toSync, toSkip);
+ }
+
+ /**
+ * Put names of stages to sync and to skip into sync extras bundle.
+ *
+ * @param bundle
+ * a <code>Bundle</code> instance (possibly null).
+ * @param stagesToSync
+ * collection of stage names to sync: key
+ * <code>EXTRAS_KEY_STAGES_TO_SYNC</code>; ignored if <code>null</code>.
+ * @param stagesToSkip
+ * collection of stage names to skip: key
+ * <code>EXTRAS_KEY_STAGES_TO_SKIP</code>; ignored if <code>null</code>.
+ */
+ public static void putStageNamesToSync(final Bundle bundle, final String[] stagesToSync, final String[] stagesToSkip) {
+ if (bundle == null) {
+ return;
+ }
+
+ if (stagesToSync != null) {
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ for (String stageName : stagesToSync) {
+ o.put(stageName, 0);
+ }
+ bundle.putString(Constants.EXTRAS_KEY_STAGES_TO_SYNC, o.toJSONString());
+ }
+
+ if (stagesToSkip != null) {
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ for (String stageName : stagesToSkip) {
+ o.put(stageName, 0);
+ }
+ bundle.putString(Constants.EXTRAS_KEY_STAGES_TO_SKIP, o.toJSONString());
+ }
+ }
+
+ /**
+ * Read contents of file as a string.
+ *
+ * @param context Android context.
+ * @param filename name of file to read; must not be null.
+ * @return <code>String</code> instance.
+ */
+ public static String readFile(final Context context, final String filename) {
+ if (filename == null) {
+ throw new IllegalArgumentException("Passed null filename in readFile.");
+ }
+
+ FileInputStream fis = null;
+ InputStreamReader isr = null;
+ BufferedReader br = null;
+
+ try {
+ fis = context.openFileInput(filename);
+ isr = new InputStreamReader(fis);
+ br = new BufferedReader(isr);
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = br.readLine()) != null) {
+ sb.append(line);
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ return null;
+ } finally {
+ if (isr != null) {
+ try {
+ isr.close();
+ } catch (IOException e) {
+ // Ignore.
+ }
+ }
+ if (fis != null) {
+ try {
+ fis.close();
+ } catch (IOException e) {
+ // Ignore.
+ }
+ }
+ }
+ }
+
+ /**
+ * Format a duration as a string, like "0.56 seconds".
+ *
+ * @param startMillis start time in milliseconds.
+ * @param endMillis end time in milliseconds.
+ * @return formatted string.
+ */
+ public static String formatDuration(long startMillis, long endMillis) {
+ final long duration = endMillis - startMillis;
+ return new DecimalFormat("#0.00 seconds").format(((double) duration) / 1000);
+ }
+
+ /**
+ * This will take a string containing a UTF-8 representation of a UTF-8
+ * byte array — e.g., "pïgéons1" — and return UTF-8 (e.g., "pïgéons1").
+ *
+ * This is the format produced by desktop Firefox when exchanging credentials
+ * containing non-ASCII characters.
+ */
+ public static String decodeUTF8(final String in) throws UnsupportedEncodingException {
+ final int length = in.length();
+ final byte[] asciiBytes = new byte[length];
+ for (int i = 0; i < length; ++i) {
+ asciiBytes[i] = (byte) in.codePointAt(i);
+ }
+ return new String(asciiBytes, "UTF-8");
+ }
+
+ /**
+ * Replace "foo@bar.com" with "XXX@XXX.XXX".
+ */
+ public static String obfuscateEmail(final String in) {
+ return in.replaceAll("[^@\\.]", "X");
+ }
+
+ public static void throwIfNull(Object... objects) {
+ for (Object object : objects) {
+ if (object == null) {
+ throw new IllegalArgumentException("object must not be null");
+ }
+ }
+ }
+
+ public static Executor newSynchronousExecutor() {
+ return new Executor() {
+ @Override
+ public void execute(Runnable runnable) {
+ runnable.run();
+ }
+ };
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoException.java
new file mode 100644
index 000000000..a8d0483c9
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoException.java
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.crypto;
+
+import java.security.GeneralSecurityException;
+
+public class CryptoException extends Exception {
+ public GeneralSecurityException cause;
+ public CryptoException(GeneralSecurityException e) {
+ this();
+ this.cause = e;
+ }
+ public CryptoException() {
+
+ }
+ private static final long serialVersionUID = -5219310989960126830L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoInfo.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoInfo.java
new file mode 100644
index 000000000..355571c6a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/CryptoInfo.java
@@ -0,0 +1,232 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.crypto;
+
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.Mac;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.mozilla.apache.commons.codec.binary.Base64;
+
+/*
+ * All info in these objects should be decoded (i.e. not BaseXX encoded).
+ */
+public class CryptoInfo {
+ private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
+ private static final String KEY_ALGORITHM_SPEC = "AES";
+
+ private byte[] message;
+ private byte[] iv;
+ private byte[] hmac;
+ private KeyBundle keys;
+
+ /**
+ * Return a CryptoInfo with given plaintext encrypted using given keys.
+ */
+ public static CryptoInfo encrypt(byte[] plaintextBytes, KeyBundle keys) throws CryptoException {
+ CryptoInfo info = new CryptoInfo(plaintextBytes, keys);
+ info.encrypt();
+ return info;
+ }
+
+ /**
+ * Return a CryptoInfo with given plaintext encrypted using given keys and initial vector.
+ */
+ public static CryptoInfo encrypt(byte[] plaintextBytes, byte[] iv, KeyBundle keys) throws CryptoException {
+ CryptoInfo info = new CryptoInfo(plaintextBytes, iv, null, keys);
+ info.encrypt();
+ return info;
+ }
+
+ /**
+ * Return a CryptoInfo with given ciphertext decrypted using given keys and initial vector, verifying that given HMAC validates.
+ */
+ public static CryptoInfo decrypt(byte[] ciphertext, byte[] iv, byte[] hmac, KeyBundle keys) throws CryptoException {
+ CryptoInfo info = new CryptoInfo(ciphertext, iv, hmac, keys);
+ info.decrypt();
+ return info;
+ }
+
+ /*
+ * Constructor typically used when encrypting.
+ */
+ public CryptoInfo(byte[] message, KeyBundle keys) {
+ this.setMessage(message);
+ this.setKeys(keys);
+ }
+
+ /*
+ * Constructor typically used when decrypting.
+ */
+ public CryptoInfo(byte[] message, byte[] iv, byte[] hmac, KeyBundle keys) {
+ this.setMessage(message);
+ this.setIV(iv);
+ this.setHMAC(hmac);
+ this.setKeys(keys);
+ }
+
+ public byte[] getMessage() {
+ return message;
+ }
+
+ public void setMessage(byte[] message) {
+ this.message = message;
+ }
+
+ public byte[] getIV() {
+ return iv;
+ }
+
+ public void setIV(byte[] iv) {
+ this.iv = iv;
+ }
+
+ public byte[] getHMAC() {
+ return hmac;
+ }
+
+ public void setHMAC(byte[] hmac) {
+ this.hmac = hmac;
+ }
+
+ public KeyBundle getKeys() {
+ return keys;
+ }
+
+ public void setKeys(KeyBundle keys) {
+ this.keys = keys;
+ }
+
+ /*
+ * Generate HMAC for given cipher text.
+ */
+ public static byte[] generatedHMACFor(byte[] message, KeyBundle keys) throws NoSuchAlgorithmException, InvalidKeyException {
+ Mac hmacHasher = HKDF.makeHMACHasher(keys.getHMACKey());
+ return hmacHasher.doFinal(Base64.encodeBase64(message));
+ }
+
+ /*
+ * Return true if generated HMAC is the same as the specified HMAC.
+ */
+ public boolean generatedHMACIsHMAC() throws NoSuchAlgorithmException, InvalidKeyException {
+ byte[] generatedHMAC = generatedHMACFor(getMessage(), getKeys());
+ byte[] expectedHMAC = getHMAC();
+ return Arrays.equals(generatedHMAC, expectedHMAC);
+ }
+
+ /**
+ * Performs functionality common to both encryption and decryption.
+ *
+ * @param cipher
+ * @param inputMessage non-BaseXX-encoded message
+ * @return encrypted/decrypted message
+ * @throws CryptoException
+ */
+ private static byte[] commonCrypto(Cipher cipher, byte[] inputMessage)
+ throws CryptoException {
+ byte[] outputMessage = null;
+ try {
+ outputMessage = cipher.doFinal(inputMessage);
+ } catch (IllegalBlockSizeException | BadPaddingException e) {
+ throw new CryptoException(e);
+ }
+ return outputMessage;
+ }
+
+ /**
+ * Encrypt a CryptoInfo in-place.
+ *
+ * @throws CryptoException
+ */
+ public void encrypt() throws CryptoException {
+
+ Cipher cipher = CryptoInfo.getCipher(TRANSFORMATION);
+ try {
+ byte[] encryptionKey = getKeys().getEncryptionKey();
+ SecretKeySpec spec = new SecretKeySpec(encryptionKey, KEY_ALGORITHM_SPEC);
+
+ // If no IV is provided, we allow the cipher to provide one.
+ if (getIV() == null || getIV().length == 0) {
+ cipher.init(Cipher.ENCRYPT_MODE, spec);
+ } else {
+ cipher.init(Cipher.ENCRYPT_MODE, spec, new IvParameterSpec(getIV()));
+ }
+ } catch (GeneralSecurityException ex) {
+ throw new CryptoException(ex);
+ }
+
+ // Encrypt.
+ byte[] encryptedBytes = commonCrypto(cipher, getMessage());
+ byte[] iv = cipher.getIV();
+
+ byte[] hmac;
+ // Generate HMAC.
+ try {
+ hmac = generatedHMACFor(encryptedBytes, keys);
+ } catch (NoSuchAlgorithmException | InvalidKeyException e) {
+ throw new CryptoException(e);
+ }
+
+ // Update in place. keys is already set.
+ this.setHMAC(hmac);
+ this.setIV(iv);
+ this.setMessage(encryptedBytes);
+ }
+
+ /**
+ * Decrypt a CryptoInfo in-place.
+ *
+ * @throws CryptoException
+ */
+ public void decrypt() throws CryptoException {
+
+ // Check HMAC.
+ try {
+ if (!generatedHMACIsHMAC()) {
+ throw new HMACVerificationException();
+ }
+ } catch (NoSuchAlgorithmException | InvalidKeyException e) {
+ throw new CryptoException(e);
+ }
+
+ Cipher cipher = CryptoInfo.getCipher(TRANSFORMATION);
+ try {
+ byte[] encryptionKey = getKeys().getEncryptionKey();
+ SecretKeySpec spec = new SecretKeySpec(encryptionKey, KEY_ALGORITHM_SPEC);
+ cipher.init(Cipher.DECRYPT_MODE, spec, new IvParameterSpec(getIV()));
+ } catch (GeneralSecurityException ex) {
+ throw new CryptoException(ex);
+ }
+ byte[] decryptedBytes = commonCrypto(cipher, getMessage());
+ byte[] iv = cipher.getIV();
+
+ // Update in place. keys is already set.
+ this.setHMAC(null);
+ this.setIV(iv);
+ this.setMessage(decryptedBytes);
+ }
+
+ /**
+ * Helper to get a Cipher object.
+ *
+ * @param transformation The type of Cipher to get.
+ */
+ private static Cipher getCipher(String transformation) throws CryptoException {
+ try {
+ return Cipher.getInstance(transformation);
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
+ throw new CryptoException(e);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HKDF.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HKDF.java
new file mode 100644
index 000000000..16c0d8147
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HKDF.java
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.crypto;
+
+import java.security.InvalidKeyException;
+import java.security.Key;
+import java.security.NoSuchAlgorithmException;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.mozilla.gecko.sync.Utils;
+
+/*
+ * A standards-compliant implementation of RFC 5869
+ * for HMAC-based Key Derivation Function.
+ * HMAC uses HMAC SHA256 standard.
+ */
+public class HKDF {
+ public static String HMAC_ALGORITHM = "hmacSHA256";
+
+ /**
+ * Used for conversion in cases in which you *know* the encoding exists.
+ */
+ public static final byte[] bytes(String in) {
+ try {
+ return in.getBytes("UTF-8");
+ } catch (java.io.UnsupportedEncodingException e) {
+ return null;
+ }
+ }
+
+ public static final int BLOCKSIZE = 256 / 8;
+ public static final byte[] HMAC_INPUT = bytes("Sync-AES_256_CBC-HMAC256");
+
+ /*
+ * Step 1 of RFC 5869
+ * Get sha256HMAC Bytes
+ * Input: salt (message), IKM (input keyring material)
+ * Output: PRK (pseudorandom key)
+ */
+ public static byte[] hkdfExtract(byte[] salt, byte[] IKM) throws NoSuchAlgorithmException, InvalidKeyException {
+ return digestBytes(IKM, makeHMACHasher(salt));
+ }
+
+ /*
+ * Step 2 of RFC 5869.
+ * Input: PRK from step 1, info, length.
+ * Output: OKM (output keyring material).
+ */
+ public static byte[] hkdfExpand(byte[] prk, byte[] info, int len) throws NoSuchAlgorithmException, InvalidKeyException {
+ Mac hmacHasher = makeHMACHasher(prk);
+
+ byte[] T = {};
+ byte[] Tn = {};
+
+ int iterations = (int) Math.ceil(((double)len) / (BLOCKSIZE));
+ for (int i = 0; i < iterations; i++) {
+ Tn = digestBytes(Utils.concatAll(Tn, info, Utils.hex2Byte(Integer.toHexString(i + 1))),
+ hmacHasher);
+ T = Utils.concatAll(T, Tn);
+ }
+
+ byte[] result = new byte[len];
+ System.arraycopy(T, 0, result, 0, len);
+ return result;
+ }
+
+ /*
+ * Make HMAC key
+ * Input: key (salt)
+ * Output: Key HMAC-Key
+ */
+ public static Key makeHMACKey(byte[] key) {
+ if (key.length == 0) {
+ key = new byte[BLOCKSIZE];
+ }
+ return new SecretKeySpec(key, HMAC_ALGORITHM);
+ }
+
+ /*
+ * Make an HMAC hasher
+ * Input: Key hmacKey
+ * Ouput: An HMAC Hasher
+ */
+ public static Mac makeHMACHasher(byte[] key) throws NoSuchAlgorithmException, InvalidKeyException {
+ Mac hmacHasher = null;
+ hmacHasher = Mac.getInstance(HMAC_ALGORITHM);
+
+ // If Mac.getInstance doesn't throw NoSuchAlgorithmException, hmacHasher is
+ // non-null.
+ assert(hmacHasher != null);
+
+ hmacHasher.init(makeHMACKey(key));
+ return hmacHasher;
+ }
+
+ /*
+ * Hash bytes with given hasher
+ * Input: message to hash, HMAC hasher
+ * Output: hashed byte[].
+ */
+ public static byte[] digestBytes(byte[] message, Mac hasher) {
+ hasher.update(message);
+ byte[] ret = hasher.doFinal();
+ hasher.reset();
+ return ret;
+ }
+
+ public static byte[] derive(byte[] skm, byte[] xts, byte[] ctxInfo, int dkLen) throws InvalidKeyException, NoSuchAlgorithmException {
+ return hkdfExpand(hkdfExtract(xts, skm), ctxInfo, dkLen);
+ }
+
+ public static void deriveMany(byte[] skm, byte[] xts, byte[] ctxInfo, byte[]... keys) throws InvalidKeyException, NoSuchAlgorithmException {
+ int length = 0;
+ for (byte[] key : keys) {
+ length += key.length;
+ }
+ byte[] derived = hkdfExpand(hkdfExtract(xts, skm), ctxInfo, length);
+ int offset = 0;
+ for (byte[] key : keys) {
+ System.arraycopy(derived, offset, key, 0, key.length);
+ offset += key.length;
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HMACVerificationException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HMACVerificationException.java
new file mode 100644
index 000000000..f33babd52
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/HMACVerificationException.java
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.crypto;
+
+public class HMACVerificationException extends CryptoException {
+ private static final long serialVersionUID = 1235311303567074897L;
+ public HMACVerificationException() {
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/KeyBundle.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/KeyBundle.java
new file mode 100644
index 000000000..2063b1e32
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/KeyBundle.java
@@ -0,0 +1,135 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.crypto;
+
+import java.io.UnsupportedEncodingException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.Mac;
+
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.sync.Utils;
+
+public class KeyBundle {
+ private static final String KEY_ALGORITHM_SPEC = "AES";
+ private static final int KEY_SIZE = 256;
+
+ private byte[] encryptionKey;
+ private byte[] hmacKey;
+
+ // These are the same for every sync key bundle.
+ private static final byte[] EMPTY_BYTES = {};
+ private static final byte[] ENCR_INPUT_BYTES = {1};
+ private static final byte[] HMAC_INPUT_BYTES = {2};
+
+ /*
+ * Mozilla's use of HKDF for getting keys from the Sync Key string.
+ *
+ * We do exactly 2 HKDF iterations and make the first iteration the
+ * encryption key and the second iteration the HMAC key.
+ *
+ */
+ public KeyBundle(String username, String base32SyncKey) throws CryptoException {
+ if (base32SyncKey == null) {
+ throw new IllegalArgumentException("No sync key provided.");
+ }
+ if (username == null || username.equals("")) {
+ throw new IllegalArgumentException("No username provided.");
+ }
+ // Hash appropriately.
+ try {
+ username = Utils.usernameFromAccount(username);
+ } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
+ throw new IllegalArgumentException("Invalid username.");
+ }
+
+ byte[] syncKey = Utils.decodeFriendlyBase32(base32SyncKey);
+ byte[] user = username.getBytes();
+
+ Mac hmacHasher;
+ try {
+ hmacHasher = HKDF.makeHMACHasher(syncKey);
+ } catch (NoSuchAlgorithmException | InvalidKeyException e) {
+ throw new CryptoException(e);
+ }
+ assert(hmacHasher != null); // If makeHMACHasher doesn't throw, then hmacHasher is non-null.
+
+ byte[] encrBytes = Utils.concatAll(EMPTY_BYTES, HKDF.HMAC_INPUT, user, ENCR_INPUT_BYTES);
+ byte[] encrKey = HKDF.digestBytes(encrBytes, hmacHasher);
+ byte[] hmacBytes = Utils.concatAll(encrKey, HKDF.HMAC_INPUT, user, HMAC_INPUT_BYTES);
+
+ this.hmacKey = HKDF.digestBytes(hmacBytes, hmacHasher);
+ this.encryptionKey = encrKey;
+ }
+
+ public KeyBundle(byte[] encryptionKey, byte[] hmacKey) {
+ this.setEncryptionKey(encryptionKey);
+ this.setHMACKey(hmacKey);
+ }
+
+ /**
+ * Make a KeyBundle with the specified base64-encoded keys.
+ *
+ * @return A KeyBundle with the specified keys.
+ */
+ public static KeyBundle fromBase64EncodedKeys(String base64EncryptionKey, String base64HmacKey) throws UnsupportedEncodingException {
+ return new KeyBundle(Base64.decodeBase64(base64EncryptionKey.getBytes("UTF-8")),
+ Base64.decodeBase64(base64HmacKey.getBytes("UTF-8")));
+ }
+
+ /**
+ * Make a KeyBundle with two random 256 bit keys (encryption and HMAC).
+ *
+ * @return A KeyBundle with random keys.
+ */
+ public static KeyBundle withRandomKeys() throws CryptoException {
+ KeyGenerator keygen;
+ try {
+ keygen = KeyGenerator.getInstance(KEY_ALGORITHM_SPEC);
+ } catch (NoSuchAlgorithmException e) {
+ throw new CryptoException(e);
+ }
+
+ keygen.init(KEY_SIZE);
+ byte[] encryptionKey = keygen.generateKey().getEncoded();
+ byte[] hmacKey = keygen.generateKey().getEncoded();
+
+ return new KeyBundle(encryptionKey, hmacKey);
+ }
+
+ public byte[] getEncryptionKey() {
+ return encryptionKey;
+ }
+
+ public void setEncryptionKey(byte[] encryptionKey) {
+ this.encryptionKey = encryptionKey;
+ }
+
+ public byte[] getHMACKey() {
+ return hmacKey;
+ }
+
+ public void setHMACKey(byte[] hmacKey) {
+ this.hmacKey = hmacKey;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof KeyBundle)) {
+ return false;
+ }
+ KeyBundle other = (KeyBundle) o;
+ return Arrays.equals(other.encryptionKey, this.encryptionKey) &&
+ Arrays.equals(other.hmacKey, this.hmacKey);
+ }
+
+ @Override
+ public int hashCode() {
+ throw new UnsupportedOperationException("No hashCode for KeyBundle.");
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/MissingCryptoInputException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/MissingCryptoInputException.java
new file mode 100644
index 000000000..8add1cf11
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/MissingCryptoInputException.java
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.crypto;
+
+public class MissingCryptoInputException extends CryptoException {
+ private static final long serialVersionUID = 5334412407012972445L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/NoKeyBundleException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/NoKeyBundleException.java
new file mode 100644
index 000000000..00e0f8b18
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/NoKeyBundleException.java
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.crypto;
+
+public class NoKeyBundleException extends CryptoException {
+ private static final long serialVersionUID = -6627154503154040915L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PBKDF2.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PBKDF2.java
new file mode 100644
index 000000000..636b2105c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PBKDF2.java
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.crypto;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+
+import javax.crypto.Mac;
+import javax.crypto.ShortBufferException;
+import javax.crypto.spec.SecretKeySpec;
+
+public class PBKDF2 {
+ public static byte[] pbkdf2SHA256(byte[] password, byte[] salt, int c, int dkLen)
+ throws GeneralSecurityException {
+ final String algorithm = "HmacSHA256";
+ SecretKeySpec keyspec = new SecretKeySpec(password, algorithm);
+ Mac prf = Mac.getInstance(algorithm);
+ prf.init(keyspec);
+
+ int hLen = prf.getMacLength();
+
+ byte U_r[] = new byte[hLen];
+ byte U_i[] = new byte[salt.length + 4];
+ byte scratch[] = new byte[hLen];
+
+ int l = Math.max(dkLen, hLen);
+ int r = dkLen - (l - 1) * hLen;
+ byte T[] = new byte[l * hLen];
+ int ti_offset = 0;
+ for (int i = 1; i <= l; i++) {
+ Arrays.fill(U_r, (byte) 0);
+ F(T, ti_offset, prf, salt, c, i, U_r, U_i, scratch);
+ ti_offset += hLen;
+ }
+
+ if (r < hLen) {
+ // Incomplete last block.
+ byte DK[] = new byte[dkLen];
+ System.arraycopy(T, 0, DK, 0, dkLen);
+ return DK;
+ }
+
+ return T;
+ }
+
+ private static void F(byte[] dest, int offset, Mac prf, byte[] S, int c, int blockIndex, byte U_r[], byte U_i[], byte[] scratch)
+ throws ShortBufferException, IllegalStateException {
+ final int hLen = prf.getMacLength();
+
+ // U0 = S || INT (i);
+ System.arraycopy(S, 0, U_i, 0, S.length);
+ INT(U_i, S.length, blockIndex);
+
+ for (int i = 0; i < c; i++) {
+ prf.update(U_i);
+ prf.doFinal(scratch, 0);
+ U_i = scratch;
+ xor(U_r, U_i);
+ }
+
+ System.arraycopy(U_r, 0, dest, offset, hLen);
+ }
+
+ private static void xor(byte[] dest, byte[] src) {
+ for (int i = 0; i < dest.length; i++) {
+ dest[i] ^= src[i];
+ }
+ }
+
+ private static void INT(byte[] dest, int offset, int i) {
+ dest[offset + 0] = (byte) (i / (256 * 256 * 256));
+ dest[offset + 1] = (byte) (i / (256 * 256));
+ dest[offset + 2] = (byte) (i / (256));
+ dest[offset + 3] = (byte) (i);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PersistedCrypto5Keys.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PersistedCrypto5Keys.java
new file mode 100644
index 000000000..4dba4f258
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/crypto/PersistedCrypto5Keys.java
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.crypto;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.CollectionKeys;
+import org.mozilla.gecko.sync.CryptoRecord;
+
+import android.content.SharedPreferences;
+
+public class PersistedCrypto5Keys {
+ public static final String LOG_TAG = "PersistedC5Keys";
+
+ public static final String CRYPTO5_KEYS_SERVER_RESPONSE_BODY = "crypto5KeysServerResponseBody";
+ public static final String CRYPTO5_KEYS_LAST_MODIFIED = "crypto5KeysLastModified";
+
+ protected SharedPreferences prefs;
+ protected KeyBundle syncKeyBundle;
+
+ public PersistedCrypto5Keys(SharedPreferences prefs, KeyBundle syncKeyBundle) {
+ if (syncKeyBundle == null) {
+ throw new IllegalArgumentException("Null syncKeyBundle passed in to PersistedCrypto5Keys constructor.");
+ }
+ this.prefs = prefs;
+ this.syncKeyBundle = syncKeyBundle;
+ }
+
+ /**
+ * Get persisted crypto/keys.
+ * <p>
+ * crypto/keys is fetched from an encrypted JSON-encoded <code>CryptoRecord</code>.
+ *
+ * @return A <code>CollectionKeys</code> instance or <code>null</code> if none
+ * is currently persisted.
+ */
+ public CollectionKeys keys() {
+ String keysJSON = prefs.getString(CRYPTO5_KEYS_SERVER_RESPONSE_BODY, null);
+ if (keysJSON == null) {
+ return null;
+ }
+ try {
+ CryptoRecord cryptoRecord = CryptoRecord.fromJSONRecord(keysJSON);
+ CollectionKeys keys = new CollectionKeys();
+ keys.setKeyPairsFromWBO(cryptoRecord, syncKeyBundle);
+ return keys;
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception decrypting persisted crypto/keys.", e);
+ return null;
+ }
+ }
+
+ /**
+ * Persist crypto/keys.
+ * <p>
+ * crypto/keys is stored as an encrypted JSON-encoded <code>CryptoRecord</code>.
+ *
+ * @param keys
+ * The <code>CollectionKeys</code> object to persist, which should
+ * have the same default key bundle as the sync key bundle.
+ */
+ public void persistKeys(CollectionKeys keys) {
+ if (keys == null) {
+ Logger.debug(LOG_TAG, "Clearing persisted crypto/keys.");
+ prefs.edit().remove(CRYPTO5_KEYS_SERVER_RESPONSE_BODY).commit();
+ return;
+ }
+ try {
+ CryptoRecord cryptoRecord = keys.asCryptoRecord();
+ cryptoRecord.keyBundle = syncKeyBundle;
+ cryptoRecord.encrypt();
+ String keysJSON = cryptoRecord.toJSONString();
+ Logger.debug(LOG_TAG, "Persisting crypto/keys.");
+ prefs.edit().putString(CRYPTO5_KEYS_SERVER_RESPONSE_BODY, keysJSON).commit();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception encrypting while persisting crypto/keys.", e);
+ }
+ }
+
+ public boolean persistedKeysExist() {
+ return lastModified() > 0;
+ }
+
+ public long lastModified() {
+ return prefs.getLong(CRYPTO5_KEYS_LAST_MODIFIED, -1);
+ }
+
+ public void persistLastModified(long lastModified) {
+ if (lastModified <= 0) {
+ Logger.debug(LOG_TAG, "Clearing persisted crypto/keys last modified timestamp.");
+ prefs.edit().remove(CRYPTO5_KEYS_LAST_MODIFIED).commit();
+ return;
+ }
+ Logger.debug(LOG_TAG, "Persisting crypto/keys last modified timestamp " + lastModified + ".");
+ prefs.edit().putLong(CRYPTO5_KEYS_LAST_MODIFIED, lastModified).commit();
+ }
+
+ public void purge() {
+ persistLastModified(-1);
+ persistKeys(null);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/ClientsDataDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/ClientsDataDelegate.java
new file mode 100644
index 000000000..07e9179f0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/ClientsDataDelegate.java
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.delegates;
+
+public interface ClientsDataDelegate {
+ public String getAccountGUID();
+ public String getDefaultClientName();
+ public void setClientName(String clientName, long now);
+ public String getClientName();
+ public void setClientsCount(int clientsCount);
+ public int getClientsCount();
+ public boolean isLocalGUID(String guid);
+ public String getFormFactor();
+
+ /**
+ * The last time the client's data was modified in a way that should be
+ * reflected remotely.
+ * <p>
+ * Changing the client's name should be reflected remotely, while changing the
+ * clients count should not (since that data is only used to inform local
+ * policy.)
+ *
+ * @return timestamp in milliseconds.
+ */
+ public long getLastModifiedTimestamp();
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/FreshStartDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/FreshStartDelegate.java
new file mode 100644
index 000000000..2e5347061
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/FreshStartDelegate.java
@@ -0,0 +1,10 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.delegates;
+
+public interface FreshStartDelegate {
+ void onFreshStart();
+ void onFreshStartFailed(Exception e);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/GlobalSessionCallback.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/GlobalSessionCallback.java
new file mode 100644
index 000000000..9829f5b34
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/GlobalSessionCallback.java
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.delegates;
+
+import java.net.URI;
+
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+
+public interface GlobalSessionCallback {
+ /**
+ * Request that no further syncs occur within the next `backoff` milliseconds.
+ * @param backoff a duration in milliseconds.
+ */
+ void requestBackoff(long backoff);
+
+ /**
+ * Called on a 401 HTTP response.
+ */
+ void informUnauthorizedResponse(GlobalSession globalSession, URI oldClusterURL);
+
+
+ /**
+ * Called when an HTTP failure indicates that a software upgrade is required.
+ */
+ void informUpgradeRequiredResponse(GlobalSession session);
+
+ /**
+ * Called when a migration sentinel has been found and processed successfully.
+ * <p>
+ * This account should stop syncing immediately, and arrange to delete itself.
+ */
+ void informMigrated(GlobalSession session);
+
+ void handleAborted(GlobalSession globalSession, String reason);
+ void handleError(GlobalSession globalSession, Exception ex);
+ void handleSuccess(GlobalSession globalSession);
+ void handleStageCompleted(Stage currentState, GlobalSession globalSession);
+
+ /**
+ * Called when a {@link GlobalSession} wants to know if it should continue
+ * to make storage requests.
+ *
+ * @return false if the session should make no further requests.
+ */
+ boolean shouldBackOffStorage();
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/JSONRecordFetchDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/JSONRecordFetchDelegate.java
new file mode 100644
index 000000000..90b73a33a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/JSONRecordFetchDelegate.java
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.delegates;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+/**
+ * A fairly generic delegate to handle fetches of single JSON object blobs, as
+ * provided by <code>info/configuration</code>, <code>info/collections</code>
+ * and <code>info/collection_counts</code>.
+ */
+public interface JSONRecordFetchDelegate {
+ public void handleSuccess(ExtendedJSONObject body);
+ public void handleFailure(SyncStorageResponse response);
+ public void handleError(Exception e);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/KeyUploadDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/KeyUploadDelegate.java
new file mode 100644
index 000000000..0cd5ec732
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/KeyUploadDelegate.java
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.delegates;
+
+public interface KeyUploadDelegate {
+ /**
+ * Called when keys have been successfully uploaded to the server.
+ * <p>
+ * The uploaded keys are intentionally not exposed. It is possible for two
+ * clients to simultaneously upload keys and for each client to conclude that
+ * its keys are current (since the server returned 200 on upload). To shorten
+ * the window wherein two such clients can race, all clients should upload and
+ * then immediately re-download the fetched keys.
+ * <p>
+ * See Bug 692700, Bug 693893.
+ */
+ void onKeysUploaded();
+ void onKeyUploadFailed(Exception e);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/MetaGlobalDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/MetaGlobalDelegate.java
new file mode 100644
index 000000000..13854cb5a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/MetaGlobalDelegate.java
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.delegates;
+
+import org.mozilla.gecko.sync.MetaGlobal;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+public interface MetaGlobalDelegate {
+ public void handleSuccess(MetaGlobal global, SyncStorageResponse response);
+ public void handleMissing(MetaGlobal global, SyncStorageResponse response);
+ public void handleFailure(SyncStorageResponse response);
+ public void handleError(Exception e);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/WipeServerDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/WipeServerDelegate.java
new file mode 100644
index 000000000..ef3565812
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/WipeServerDelegate.java
@@ -0,0 +1,10 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.delegates;
+
+public interface WipeServerDelegate {
+ public void onWiped(long timestamp);
+ public void onWipeFailed(Exception e);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepository.java
new file mode 100644
index 000000000..79319aff5
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepository.java
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.middleware;
+
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.repositories.IdentityRecordFactory;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCleanDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+
+import android.content.Context;
+
+/**
+ * Wrap an existing repository in middleware that encrypts and decrypts records
+ * passing through.
+ *
+ * @author rnewman
+ *
+ */
+public class Crypto5MiddlewareRepository extends MiddlewareRepository {
+
+ public RecordFactory recordFactory = new IdentityRecordFactory();
+
+ public class Crypto5MiddlewareRepositorySessionCreationDelegate extends MiddlewareRepository.SessionCreationDelegate {
+ private final Crypto5MiddlewareRepository repository;
+ private final RepositorySessionCreationDelegate outerDelegate;
+
+ public Crypto5MiddlewareRepositorySessionCreationDelegate(Crypto5MiddlewareRepository repository, RepositorySessionCreationDelegate outerDelegate) {
+ this.repository = repository;
+ this.outerDelegate = outerDelegate;
+ }
+
+ @Override
+ public void onSessionCreateFailed(Exception ex) {
+ this.outerDelegate.onSessionCreateFailed(ex);
+ }
+
+ @Override
+ public void onSessionCreated(RepositorySession session) {
+ // Do some work, then report success with the wrapping session.
+ Crypto5MiddlewareRepositorySession cryptoSession;
+ try {
+ // Synchronous, baby.
+ cryptoSession = new Crypto5MiddlewareRepositorySession(session, this.repository, recordFactory);
+ } catch (Exception ex) {
+ this.outerDelegate.onSessionCreateFailed(ex);
+ return;
+ }
+ this.outerDelegate.onSessionCreated(cryptoSession);
+ }
+ }
+
+ public KeyBundle keyBundle;
+ private final Repository inner;
+
+ public Crypto5MiddlewareRepository(Repository inner, KeyBundle keys) {
+ super();
+ this.inner = inner;
+ this.keyBundle = keys;
+ }
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate, Context context) {
+ Crypto5MiddlewareRepositorySessionCreationDelegate delegateWrapper = new Crypto5MiddlewareRepositorySessionCreationDelegate(this, delegate);
+ inner.createSession(delegateWrapper, context);
+ }
+
+ @Override
+ public void clean(boolean success, RepositorySessionCleanDelegate delegate,
+ Context context) {
+ this.inner.clean(success, delegate, context);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepositorySession.java
new file mode 100644
index 000000000..46de7a236
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/Crypto5MiddlewareRepositorySession.java
@@ -0,0 +1,172 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.middleware;
+
+import java.io.UnsupportedEncodingException;
+import java.util.concurrent.ExecutorService;
+
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.NoStoreDelegateException;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+/**
+ * It's a RepositorySession that accepts Records as input, producing CryptoRecords
+ * for submission to a remote service.
+ * Takes a RecordFactory as a parameter. This is in charge of taking decrypted CryptoRecords
+ * as input and producing some expected kind of Record as output for local use.
+ *
+ *
+
+
+
+ +------------------------------------+
+ | Server11RepositorySession |
+ +-------------------------+----------+
+ ^ |
+ | |
+ Encrypted CryptoRecords
+ | |
+ | v
+ +---------+--------------------------+
+ | Crypto5MiddlewareRepositorySession |
+ +------------------------------------+
+ ^ |
+ | | Decrypted CryptoRecords
+ | |
+ | +---------------+
+ | | RecordFactory |
+ | +--+------------+
+ | |
+ Local Record instances
+ | |
+ | v
+ +---------+--------------------------+
+ | Local RepositorySession instance |
+ +------------------------------------+
+
+
+ * @author rnewman
+ *
+ */
+public class Crypto5MiddlewareRepositorySession extends MiddlewareRepositorySession {
+ private final KeyBundle keyBundle;
+ private final RecordFactory recordFactory;
+
+ public Crypto5MiddlewareRepositorySession(RepositorySession session, Crypto5MiddlewareRepository repository, RecordFactory recordFactory) {
+ super(session, repository);
+ this.keyBundle = repository.keyBundle;
+ this.recordFactory = recordFactory;
+ }
+
+ public class DecryptingTransformingFetchDelegate implements RepositorySessionFetchRecordsDelegate {
+ private final RepositorySessionFetchRecordsDelegate next;
+ private final KeyBundle keyBundle;
+ private final RecordFactory recordFactory;
+
+ DecryptingTransformingFetchDelegate(RepositorySessionFetchRecordsDelegate next, KeyBundle bundle, RecordFactory recordFactory) {
+ this.next = next;
+ this.keyBundle = bundle;
+ this.recordFactory = recordFactory;
+ }
+
+ @Override
+ public void onFetchFailed(Exception ex, Record record) {
+ next.onFetchFailed(ex, record);
+ }
+
+ @Override
+ public void onFetchedRecord(Record record) {
+ CryptoRecord r;
+ try {
+ r = (CryptoRecord) record;
+ } catch (ClassCastException e) {
+ next.onFetchFailed(e, record);
+ return;
+ }
+ r.keyBundle = keyBundle;
+ try {
+ r.decrypt();
+ } catch (Exception e) {
+ next.onFetchFailed(e, r);
+ return;
+ }
+ Record transformed;
+ try {
+ transformed = this.recordFactory.createRecord(r);
+ } catch (Exception e) {
+ next.onFetchFailed(e, r);
+ return;
+ }
+ next.onFetchedRecord(transformed);
+ }
+
+ @Override
+ public void onFetchCompleted(final long fetchEnd) {
+ next.onFetchCompleted(fetchEnd);
+ }
+
+ @Override
+ public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor) {
+ // Synchronously perform *our* work, passing through appropriately.
+ RepositorySessionFetchRecordsDelegate deferredNext = next.deferredFetchDelegate(executor);
+ return new DecryptingTransformingFetchDelegate(deferredNext, keyBundle, recordFactory);
+ }
+ }
+
+ private DecryptingTransformingFetchDelegate makeUnwrappingDelegate(RepositorySessionFetchRecordsDelegate inner) {
+ if (inner == null) {
+ throw new IllegalArgumentException("Inner delegate cannot be null!");
+ }
+ return new DecryptingTransformingFetchDelegate(inner, this.keyBundle, this.recordFactory);
+ }
+
+ @Override
+ public void fetchSince(long timestamp,
+ RepositorySessionFetchRecordsDelegate delegate) {
+ inner.fetchSince(timestamp, makeUnwrappingDelegate(delegate));
+ }
+
+ @Override
+ public void fetch(String[] guids,
+ RepositorySessionFetchRecordsDelegate delegate) throws InactiveSessionException {
+ inner.fetch(guids, makeUnwrappingDelegate(delegate));
+ }
+
+ @Override
+ public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) {
+ inner.fetchAll(makeUnwrappingDelegate(delegate));
+ }
+
+ @Override
+ public void setStoreDelegate(RepositorySessionStoreDelegate delegate) {
+ // TODO: it remains to be seen how this will work.
+ inner.setStoreDelegate(delegate);
+ this.delegate = delegate; // So we can handle errors without involving inner.
+ }
+
+ @Override
+ public void store(Record record) throws NoStoreDelegateException {
+ if (delegate == null) {
+ throw new NoStoreDelegateException();
+ }
+ CryptoRecord rec = record.getEnvelope();
+ rec.keyBundle = this.keyBundle;
+ try {
+ rec.encrypt();
+ } catch (UnsupportedEncodingException | CryptoException e) {
+ delegate.onRecordStoreFailed(e, record.guid);
+ return;
+ }
+ // Allow the inner session to do delegate handling.
+ inner.store(rec);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepository.java
new file mode 100644
index 000000000..d807aa5c0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepository.java
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.middleware;
+
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+
+public abstract class MiddlewareRepository extends Repository {
+
+ public abstract class SessionCreationDelegate implements
+ RepositorySessionCreationDelegate {
+
+ // We call through to our inner repository, so we don't need our own
+ // deferral scheme.
+ @Override
+ public RepositorySessionCreationDelegate deferredCreationDelegate() {
+ return this;
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepositorySession.java
new file mode 100644
index 000000000..e14ef5226
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/middleware/MiddlewareRepositorySession.java
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.middleware;
+
+import java.util.concurrent.ExecutorService;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
+
+public abstract class MiddlewareRepositorySession extends RepositorySession {
+ private static final String LOG_TAG = "MiddlewareSession";
+ protected final RepositorySession inner;
+
+ public MiddlewareRepositorySession(RepositorySession innerSession, MiddlewareRepository repository) {
+ super(repository);
+ this.inner = innerSession;
+ }
+
+ @Override
+ public void wipe(RepositorySessionWipeDelegate delegate) {
+ inner.wipe(delegate);
+ }
+
+ public class MiddlewareRepositorySessionBeginDelegate implements RepositorySessionBeginDelegate {
+
+ private final MiddlewareRepositorySession outerSession;
+ private final RepositorySessionBeginDelegate next;
+
+ public MiddlewareRepositorySessionBeginDelegate(MiddlewareRepositorySession outerSession, RepositorySessionBeginDelegate next) {
+ this.outerSession = outerSession;
+ this.next = next;
+ }
+
+ @Override
+ public void onBeginFailed(Exception ex) {
+ next.onBeginFailed(ex);
+ }
+
+ @Override
+ public void onBeginSucceeded(RepositorySession session) {
+ next.onBeginSucceeded(outerSession);
+ }
+
+ @Override
+ public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor) {
+ final RepositorySessionBeginDelegate deferred = next.deferredBeginDelegate(executor);
+ return new RepositorySessionBeginDelegate() {
+ @Override
+ public void onBeginSucceeded(RepositorySession session) {
+ if (inner != session) {
+ Logger.warn(LOG_TAG, "Got onBeginSucceeded for session " + session + ", not our inner session!");
+ }
+ deferred.onBeginSucceeded(outerSession);
+ }
+
+ @Override
+ public void onBeginFailed(Exception ex) {
+ deferred.onBeginFailed(ex);
+ }
+
+ @Override
+ public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor) {
+ return this;
+ }
+ };
+ }
+ }
+
+ @Override
+ public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
+ inner.begin(new MiddlewareRepositorySessionBeginDelegate(this, delegate));
+ }
+
+ public class MiddlewareRepositorySessionFinishDelegate implements RepositorySessionFinishDelegate {
+ private final MiddlewareRepositorySession outerSession;
+ private final RepositorySessionFinishDelegate next;
+
+ public MiddlewareRepositorySessionFinishDelegate(MiddlewareRepositorySession outerSession, RepositorySessionFinishDelegate next) {
+ this.outerSession = outerSession;
+ this.next = next;
+ }
+
+ @Override
+ public void onFinishFailed(Exception ex) {
+ next.onFinishFailed(ex);
+ }
+
+ @Override
+ public void onFinishSucceeded(RepositorySession session, RepositorySessionBundle bundle) {
+ next.onFinishSucceeded(outerSession, bundle);
+ }
+
+ @Override
+ public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService executor) {
+ return this;
+ }
+ }
+
+ @Override
+ public void finish(RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
+ inner.finish(new MiddlewareRepositorySessionFinishDelegate(this, delegate));
+ }
+
+
+ @Override
+ public synchronized void ensureActive() throws InactiveSessionException {
+ inner.ensureActive();
+ }
+
+ @Override
+ public synchronized boolean isActive() {
+ return inner.isActive();
+ }
+
+ @Override
+ public synchronized SessionStatus getStatus() {
+ return inner.getStatus();
+ }
+
+ @Override
+ public synchronized void setStatus(SessionStatus status) {
+ inner.setStatus(status);
+ }
+
+ @Override
+ public synchronized void transitionFrom(SessionStatus from, SessionStatus to)
+ throws InvalidSessionTransitionException {
+ inner.transitionFrom(from, to);
+ }
+
+ @Override
+ public void abort() {
+ inner.abort();
+ }
+
+ @Override
+ public void abort(RepositorySessionFinishDelegate delegate) {
+ inner.abort(new MiddlewareRepositorySessionFinishDelegate(this, delegate));
+ }
+
+ @Override
+ public void guidsSince(long timestamp, RepositorySessionGuidsSinceDelegate delegate) {
+ // TODO: need to do anything here?
+ inner.guidsSince(timestamp, delegate);
+ }
+
+ @Override
+ public void storeDone() {
+ inner.storeDone();
+ }
+
+ @Override
+ public void storeDone(long storeEnd) {
+ inner.storeDone(storeEnd);
+ }
+
+ @Override
+ public boolean shouldSkip() {
+ return inner.shouldSkip();
+ }
+
+ @Override
+ public boolean dataAvailable() {
+ return inner.dataAvailable();
+ }
+
+ @Override
+ public void unbundle(RepositorySessionBundle bundle) {
+ inner.unbundle(bundle);
+ }
+
+ @Override
+ public long getLastSyncTimestamp() {
+ return inner.getLastSyncTimestamp();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AbstractBearerTokenAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AbstractBearerTokenAuthHeaderProvider.java
new file mode 100644
index 000000000..e3b4f25b1
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AbstractBearerTokenAuthHeaderProvider.java
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+import ch.boye.httpclientandroidlib.message.BasicHeader;
+import ch.boye.httpclientandroidlib.protocol.BasicHttpContext;
+
+/**
+ * An <code>AuthHeaderProvider</code> that returns an Authorization header for
+ * bearer tokens, adding a simple prefix.
+ */
+public abstract class AbstractBearerTokenAuthHeaderProvider implements AuthHeaderProvider {
+ protected final String header;
+
+ public AbstractBearerTokenAuthHeaderProvider(String token) {
+ if (token == null) {
+ throw new IllegalArgumentException("token must not be null.");
+ }
+
+ this.header = getPrefix() + " " + token;
+ }
+
+ protected abstract String getPrefix();
+
+ @Override
+ public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) {
+ return new BasicHeader("Authorization", header);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AuthHeaderProvider.java
new file mode 100644
index 000000000..7be6fef3d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/AuthHeaderProvider.java
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.security.GeneralSecurityException;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+import ch.boye.httpclientandroidlib.protocol.BasicHttpContext;
+
+/**
+ * An <code>AuthHeaderProvider</code> generates HTTP Authorization headers for
+ * HTTP requests.
+ */
+public interface AuthHeaderProvider {
+ /**
+ * Generate an HTTP Authorization header.
+ *
+ * @param request HTTP request.
+ * @param context HTTP context.
+ * @param client HTTP client.
+ * @return HTTP Authorization header.
+ * @throws GeneralSecurityException usually wrapping a more specific exception.
+ */
+ Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client)
+ throws GeneralSecurityException;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.java
new file mode 100644
index 000000000..60bbc86bb
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.java
@@ -0,0 +1,565 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.lang.ref.WeakReference;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.security.KeyManagementException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import javax.net.ssl.SSLContext;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.HttpVersion;
+import ch.boye.httpclientandroidlib.client.AuthCache;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.entity.GzipCompressingEntity;
+import ch.boye.httpclientandroidlib.client.methods.HttpDelete;
+import ch.boye.httpclientandroidlib.client.methods.HttpGet;
+import ch.boye.httpclientandroidlib.client.methods.HttpPatch;
+import ch.boye.httpclientandroidlib.client.methods.HttpPost;
+import ch.boye.httpclientandroidlib.client.methods.HttpPut;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
+import ch.boye.httpclientandroidlib.client.protocol.ClientContext;
+import ch.boye.httpclientandroidlib.conn.ClientConnectionManager;
+import ch.boye.httpclientandroidlib.conn.scheme.PlainSocketFactory;
+import ch.boye.httpclientandroidlib.conn.scheme.Scheme;
+import ch.boye.httpclientandroidlib.conn.scheme.SchemeRegistry;
+import ch.boye.httpclientandroidlib.conn.ssl.SSLSocketFactory;
+import ch.boye.httpclientandroidlib.entity.StringEntity;
+import ch.boye.httpclientandroidlib.impl.client.BasicAuthCache;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+import ch.boye.httpclientandroidlib.impl.conn.tsccm.ThreadSafeClientConnManager;
+import ch.boye.httpclientandroidlib.params.HttpConnectionParams;
+import ch.boye.httpclientandroidlib.params.HttpParams;
+import ch.boye.httpclientandroidlib.params.HttpProtocolParams;
+import ch.boye.httpclientandroidlib.protocol.BasicHttpContext;
+import ch.boye.httpclientandroidlib.protocol.HttpContext;
+import ch.boye.httpclientandroidlib.util.EntityUtils;
+
+/**
+ * Provide simple HTTP access to a Sync server or similar.
+ * Implements Basic Auth by asking its delegate for credentials.
+ * Communicates with a ResourceDelegate to asynchronously return responses and errors.
+ * Exposes simple get/post/put/delete methods.
+ */
+@SuppressWarnings("deprecation")
+public class BaseResource implements Resource {
+ private static final String ANDROID_LOOPBACK_IP = "10.0.2.2";
+
+ private static final int MAX_TOTAL_CONNECTIONS = 20;
+ private static final int MAX_CONNECTIONS_PER_ROUTE = 10;
+
+ private boolean retryOnFailedRequest = true;
+
+ public static boolean rewriteLocalhost = true;
+
+ private static final String LOG_TAG = "BaseResource";
+
+ protected final URI uri;
+ protected BasicHttpContext context;
+ protected DefaultHttpClient client;
+ public ResourceDelegate delegate;
+ protected HttpRequestBase request;
+ public final String charset = "utf-8";
+
+ private boolean shouldGzipCompress = false;
+ // A hint whether uploaded payloads are chunked. Default true to use GzipCompressingEntity, which is built-in functionality.
+ private boolean shouldChunkUploadsHint = true;
+
+ /**
+ * We have very few writes (observers tend to be installed around sync
+ * sessions) and many iterations (every HTTP request iterates observers), so
+ * CopyOnWriteArrayList is a reasonable choice.
+ */
+ protected static final CopyOnWriteArrayList<WeakReference<HttpResponseObserver>>
+ httpResponseObservers = new CopyOnWriteArrayList<>();
+
+ public BaseResource(String uri) throws URISyntaxException {
+ this(uri, rewriteLocalhost);
+ }
+
+ public BaseResource(URI uri) {
+ this(uri, rewriteLocalhost);
+ }
+
+ public BaseResource(String uri, boolean rewrite) throws URISyntaxException {
+ this(new URI(uri), rewrite);
+ }
+
+ public BaseResource(URI uri, boolean rewrite) {
+ if (uri == null) {
+ throw new IllegalArgumentException("uri must not be null");
+ }
+ if (rewrite && "localhost".equals(uri.getHost())) {
+ // Rewrite localhost URIs to refer to the special Android emulator loopback passthrough interface.
+ Logger.debug(LOG_TAG, "Rewriting " + uri + " to point to " + ANDROID_LOOPBACK_IP + ".");
+ try {
+ this.uri = new URI(uri.getScheme(), uri.getUserInfo(), ANDROID_LOOPBACK_IP, uri.getPort(), uri.getPath(), uri.getQuery(), uri.getFragment());
+ } catch (URISyntaxException e) {
+ Logger.error(LOG_TAG, "Got error rewriting URI for Android emulator.", e);
+ throw new IllegalArgumentException("Invalid URI", e);
+ }
+ } else {
+ this.uri = uri;
+ }
+ }
+
+ public static void addHttpResponseObserver(HttpResponseObserver newHttpResponseObserver) {
+ if (newHttpResponseObserver == null) {
+ return;
+ }
+ httpResponseObservers.add(new WeakReference<HttpResponseObserver>(newHttpResponseObserver));
+ }
+
+ public static boolean isHttpResponseObserver(HttpResponseObserver httpResponseObserver) {
+ for (WeakReference<HttpResponseObserver> weakReference : httpResponseObservers) {
+ HttpResponseObserver innerHttpResponseObserver = weakReference.get();
+ if (innerHttpResponseObserver == httpResponseObserver) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static boolean removeHttpResponseObserver(HttpResponseObserver httpResponseObserver) {
+ for (WeakReference<HttpResponseObserver> weakReference : httpResponseObservers) {
+ HttpResponseObserver innerHttpResponseObserver = weakReference.get();
+ if (innerHttpResponseObserver == httpResponseObserver) {
+ // It's safe to mutate the observers while iterating.
+ httpResponseObservers.remove(weakReference);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public URI getURI() {
+ return this.uri;
+ }
+
+ @Override
+ public String getURIString() {
+ return this.uri.toString();
+ }
+
+ @Override
+ public String getHostname() {
+ return this.getURI().getHost();
+ }
+
+ /**
+ * Causes the Resource to compress the uploaded entity payload in requests with payloads (e.g. post, put)
+ * @param shouldCompress true if the entity should be compressed, false otherwise
+ */
+ public void setShouldCompressUploadedEntity(final boolean shouldCompress) {
+ shouldGzipCompress = shouldCompress;
+ }
+
+ /**
+ * Causes the Resource to chunk the uploaded entity payload in requests with payloads (e.g. post, put).
+ * Note: this flag is only a hint - chunking is not guaranteed.
+ *
+ * Chunking is currently supported with gzip compression.
+ *
+ * @param shouldChunk true if the transfer should be chunked, false otherwise
+ */
+ public void setShouldChunkUploadsHint(final boolean shouldChunk) {
+ shouldChunkUploadsHint = shouldChunk;
+ }
+
+ private HttpEntity getMaybeCompressedEntity(final HttpEntity entity) {
+ if (!shouldGzipCompress) {
+ return entity;
+ }
+
+ return shouldChunkUploadsHint ? new GzipCompressingEntity(entity) : new GzipNonChunkedCompressingEntity(entity);
+ }
+
+ /**
+ * This shuts up HttpClient, which will otherwise debug log about there
+ * being no auth cache in the context.
+ */
+ private static void addAuthCacheToContext(HttpUriRequest request, HttpContext context) {
+ AuthCache authCache = new BasicAuthCache(); // Not thread safe.
+ context.setAttribute(ClientContext.AUTH_CACHE, authCache);
+ }
+
+ /**
+ * Invoke this after delegate and request have been set.
+ * @throws NoSuchAlgorithmException
+ * @throws KeyManagementException
+ */
+ protected void prepareClient() throws KeyManagementException, NoSuchAlgorithmException, GeneralSecurityException {
+ context = new BasicHttpContext();
+
+ // We could reuse these client instances, except that we mess around
+ // with their parameters… so we'd need a pool of some kind.
+ client = new DefaultHttpClient(getConnectionManager());
+
+ // TODO: Eventually we should use Apache HttpAsyncClient. It's not out of alpha yet.
+ // Until then, we synchronously make the request, then invoke our delegate's callback.
+ AuthHeaderProvider authHeaderProvider = delegate.getAuthHeaderProvider();
+ if (authHeaderProvider != null) {
+ Header authHeader = authHeaderProvider.getAuthHeader(request, context, client);
+ if (authHeader != null) {
+ request.addHeader(authHeader);
+ Logger.debug(LOG_TAG, "Added auth header.");
+ }
+ }
+
+ addAuthCacheToContext(request, context);
+
+ HttpParams params = client.getParams();
+ HttpConnectionParams.setConnectionTimeout(params, delegate.connectionTimeout());
+ HttpConnectionParams.setSoTimeout(params, delegate.socketTimeout());
+ HttpConnectionParams.setStaleCheckingEnabled(params, false);
+ HttpProtocolParams.setContentCharset(params, charset);
+ HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
+ final String userAgent = delegate.getUserAgent();
+ if (userAgent != null) {
+ HttpProtocolParams.setUserAgent(params, userAgent);
+ }
+ delegate.addHeaders(request, client);
+ }
+
+ private static final Object connManagerMonitor = new Object();
+ private static ClientConnectionManager connManager;
+
+ // Call within a synchronized block on connManagerMonitor.
+ private static ClientConnectionManager enableTLSConnectionManager() throws KeyManagementException, NoSuchAlgorithmException {
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, null, new SecureRandom());
+
+ Logger.debug(LOG_TAG, "Using protocols and cipher suites for Android API " + android.os.Build.VERSION.SDK_INT);
+ SSLSocketFactory sf = new SSLSocketFactory(sslContext, GlobalConstants.DEFAULT_PROTOCOLS, GlobalConstants.DEFAULT_CIPHER_SUITES, null);
+ SchemeRegistry schemeRegistry = new SchemeRegistry();
+ schemeRegistry.register(new Scheme("https", 443, sf));
+ schemeRegistry.register(new Scheme("http", 80, new PlainSocketFactory()));
+ ThreadSafeClientConnManager cm = new ThreadSafeClientConnManager(schemeRegistry);
+
+ cm.setMaxTotal(MAX_TOTAL_CONNECTIONS);
+ cm.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE);
+ connManager = cm;
+ return cm;
+ }
+
+ public static ClientConnectionManager getConnectionManager() throws KeyManagementException, NoSuchAlgorithmException
+ {
+ // TODO: shutdown.
+ synchronized (connManagerMonitor) {
+ if (connManager != null) {
+ return connManager;
+ }
+ return enableTLSConnectionManager();
+ }
+ }
+
+ /**
+ * Do some cleanup, so we don't need the stale connection check.
+ */
+ public static void closeExpiredConnections() {
+ ClientConnectionManager connectionManager;
+ synchronized (connManagerMonitor) {
+ connectionManager = connManager;
+ }
+ if (connectionManager == null) {
+ return;
+ }
+ Logger.trace(LOG_TAG, "Closing expired connections.");
+ connectionManager.closeExpiredConnections();
+ }
+
+ public static void shutdownConnectionManager() {
+ ClientConnectionManager connectionManager;
+ synchronized (connManagerMonitor) {
+ connectionManager = connManager;
+ connManager = null;
+ }
+ if (connectionManager == null) {
+ return;
+ }
+ Logger.debug(LOG_TAG, "Shutting down connection manager.");
+ connectionManager.shutdown();
+ }
+
+ private void execute() {
+ HttpResponse response;
+ try {
+ response = client.execute(request, context);
+ Logger.debug(LOG_TAG, "Response: " + response.getStatusLine().toString());
+ } catch (ClientProtocolException e) {
+ delegate.handleHttpProtocolException(e);
+ return;
+ } catch (IOException e) {
+ Logger.debug(LOG_TAG, "I/O exception returned from execute.");
+ if (!retryOnFailedRequest) {
+ delegate.handleHttpIOException(e);
+ } else {
+ retryRequest();
+ }
+ return;
+ } catch (Exception e) {
+ // Bug 740731: Don't let an exception fall through. Wrapping isn't
+ // optimal, but often the exception is treated as an Exception anyway.
+ if (!retryOnFailedRequest) {
+ // Bug 769671: IOException(Throwable cause) was added only in API level 9.
+ final IOException ex = new IOException();
+ ex.initCause(e);
+ delegate.handleHttpIOException(ex);
+ } else {
+ retryRequest();
+ }
+ return;
+ }
+
+ // Don't retry if the observer or delegate throws!
+ for (WeakReference<HttpResponseObserver> weakReference : httpResponseObservers) {
+ HttpResponseObserver observer = weakReference.get();
+ if (observer != null) {
+ observer.observeHttpResponse(request, response);
+ }
+ }
+ delegate.handleHttpResponse(response);
+ }
+
+ private void retryRequest() {
+ // Only retry once.
+ retryOnFailedRequest = false;
+ Logger.debug(LOG_TAG, "Retrying request...");
+ this.execute();
+ }
+
+ private void go(HttpRequestBase request) {
+ if (delegate == null) {
+ throw new IllegalArgumentException("No delegate provided.");
+ }
+ this.request = request;
+ try {
+ this.prepareClient();
+ } catch (KeyManagementException e) {
+ Logger.error(LOG_TAG, "Couldn't prepare client.", e);
+ delegate.handleTransportException(e);
+ return;
+ } catch (GeneralSecurityException e) {
+ Logger.error(LOG_TAG, "Couldn't prepare client.", e);
+ delegate.handleTransportException(e);
+ return;
+ } catch (Exception e) {
+ // Bug 740731: Don't let an exception fall through. Wrapping isn't
+ // optimal, but often the exception is treated as an Exception anyway.
+ delegate.handleTransportException(new GeneralSecurityException(e));
+ return;
+ }
+ this.execute();
+ }
+
+ @Override
+ public void get() {
+ Logger.debug(LOG_TAG, "HTTP GET " + this.uri.toASCIIString());
+ this.go(new HttpGet(this.uri));
+ }
+
+ /**
+ * Perform an HTTP GET as with {@link BaseResource#get()}, returning only
+ * after callbacks have been invoked.
+ */
+ public void getBlocking() {
+ // Until we use the asynchronous Apache HttpClient, we can simply call
+ // through.
+ this.get();
+ }
+
+ @Override
+ public void delete() {
+ Logger.debug(LOG_TAG, "HTTP DELETE " + this.uri.toASCIIString());
+ this.go(new HttpDelete(this.uri));
+ }
+
+ @Override
+ public void post(HttpEntity body) {
+ Logger.debug(LOG_TAG, "HTTP POST " + this.uri.toASCIIString());
+ body = getMaybeCompressedEntity(body);
+ HttpPost request = new HttpPost(this.uri);
+ request.setEntity(body);
+ this.go(request);
+ }
+
+ @Override
+ public void patch(HttpEntity body) {
+ Logger.debug(LOG_TAG, "HTTP PATCH " + this.uri.toASCIIString());
+ body = getMaybeCompressedEntity(body);
+ HttpPatch request = new HttpPatch(this.uri);
+ request.setEntity(body);
+ this.go(request);
+ }
+
+ @Override
+ public void put(HttpEntity body) {
+ Logger.debug(LOG_TAG, "HTTP PUT " + this.uri.toASCIIString());
+ body = getMaybeCompressedEntity(body);
+ HttpPut request = new HttpPut(this.uri);
+ request.setEntity(body);
+ this.go(request);
+ }
+
+ protected static StringEntity stringEntityWithContentTypeApplicationJSON(String s) {
+ StringEntity e = new StringEntity(s, "UTF-8");
+ e.setContentType("application/json");
+ return e;
+ }
+
+ /**
+ * Helper for turning a JSON object into a payload.
+ * @throws UnsupportedEncodingException
+ */
+ protected static StringEntity jsonEntity(JSONObject body) {
+ return stringEntityWithContentTypeApplicationJSON(body.toJSONString());
+ }
+
+ /**
+ * Helper for turning an extended JSON object into a payload.
+ * @throws UnsupportedEncodingException
+ */
+ protected static StringEntity jsonEntity(ExtendedJSONObject body) {
+ return stringEntityWithContentTypeApplicationJSON(body.toJSONString());
+ }
+
+ /**
+ * Helper for turning a JSON array into a payload.
+ * @throws UnsupportedEncodingException
+ */
+ protected static HttpEntity jsonEntity(JSONArray toPOST) throws UnsupportedEncodingException {
+ return stringEntityWithContentTypeApplicationJSON(toPOST.toJSONString());
+ }
+
+ /**
+ * Best-effort attempt to ensure that the entity has been fully consumed and
+ * that the underlying stream has been closed.
+ *
+ * This releases the connection back to the connection pool.
+ *
+ * @param entity The HttpEntity to be consumed.
+ */
+ public static void consumeEntity(HttpEntity entity) {
+ try {
+ EntityUtils.consume(entity);
+ } catch (IOException e) {
+ // Doesn't matter.
+ }
+ }
+
+ /**
+ * Best-effort attempt to ensure that the entity corresponding to the given
+ * HTTP response has been fully consumed and that the underlying stream has
+ * been closed.
+ *
+ * This releases the connection back to the connection pool.
+ *
+ * @param response
+ * The HttpResponse to be consumed.
+ */
+ public static void consumeEntity(HttpResponse response) {
+ if (response == null) {
+ return;
+ }
+ try {
+ EntityUtils.consume(response.getEntity());
+ } catch (IOException e) {
+ }
+ }
+
+ /**
+ * Best-effort attempt to ensure that the entity corresponding to the given
+ * Sync storage response has been fully consumed and that the underlying
+ * stream has been closed.
+ *
+ * This releases the connection back to the connection pool.
+ *
+ * @param response
+ * The SyncStorageResponse to be consumed.
+ */
+ public static void consumeEntity(SyncStorageResponse response) {
+ if (response.httpResponse() == null) {
+ return;
+ }
+ consumeEntity(response.httpResponse());
+ }
+
+ /**
+ * Best-effort attempt to ensure that the reader has been fully consumed, so
+ * that the underlying stream will be closed.
+ *
+ * This should allow the connection to be released back to the connection pool.
+ *
+ * @param reader The BufferedReader to be consumed.
+ */
+ public static void consumeReader(BufferedReader reader) {
+ try {
+ reader.close();
+ } catch (IOException e) {
+ // Do nothing.
+ }
+ }
+
+ public void post(JSONArray jsonArray) throws UnsupportedEncodingException {
+ post(jsonEntity(jsonArray));
+ }
+
+ public void put(JSONObject jsonObject) throws UnsupportedEncodingException {
+ put(jsonEntity(jsonObject));
+ }
+
+ public void put(ExtendedJSONObject o) {
+ put(jsonEntity(o));
+ }
+
+ public void post(ExtendedJSONObject o) {
+ post(jsonEntity(o));
+ }
+
+ /**
+ * Perform an HTTP POST as with {@link BaseResource#post(ExtendedJSONObject)}, returning only
+ * after callbacks have been invoked.
+ */
+ public void postBlocking(final ExtendedJSONObject o) {
+ // Until we use the asynchronous Apache HttpClient, we can simply call
+ // through.
+ post(jsonEntity(o));
+ }
+
+ public void post(JSONObject jsonObject) throws UnsupportedEncodingException {
+ post(jsonEntity(jsonObject));
+ }
+
+ public void patch(JSONArray jsonArray) throws UnsupportedEncodingException {
+ patch(jsonEntity(jsonArray));
+ }
+
+ public void patch(ExtendedJSONObject o) {
+ patch(jsonEntity(o));
+ }
+
+ public void patch(JSONObject jsonObject) throws UnsupportedEncodingException {
+ patch(jsonEntity(jsonObject));
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResourceDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResourceDelegate.java
new file mode 100644
index 000000000..84ae7a3d5
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResourceDelegate.java
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+
+/**
+ * Shared abstract class for resource delegate that use the same timeouts
+ * and no credentials.
+ *
+ * @author rnewman
+ *
+ */
+public abstract class BaseResourceDelegate implements ResourceDelegate {
+ public static int connectionTimeoutInMillis = 1000 * 30; // Wait 30s for a connection to open.
+ public static int socketTimeoutInMillis = 1000 * 2 * 60; // Wait 2 minutes for data.
+
+ protected Resource resource;
+ public BaseResourceDelegate(Resource resource) {
+ this.resource = resource;
+ }
+
+ @Override
+ public int connectionTimeout() {
+ return connectionTimeoutInMillis;
+ }
+
+ @Override
+ public int socketTimeout() {
+ return socketTimeoutInMillis;
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return null;
+ }
+
+ @Override
+ public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BasicAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BasicAuthHeaderProvider.java
new file mode 100644
index 000000000..d8a371ddc
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BasicAuthHeaderProvider.java
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.auth.Credentials;
+import ch.boye.httpclientandroidlib.auth.UsernamePasswordCredentials;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.auth.BasicScheme;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+import ch.boye.httpclientandroidlib.protocol.BasicHttpContext;
+
+/**
+ * An <code>AuthHeaderProvider</code> that returns an HTTP Basic auth header.
+ */
+public class BasicAuthHeaderProvider implements AuthHeaderProvider {
+ protected final String credentials;
+
+ /**
+ * Constructor.
+ *
+ * @param credentials string in form "user:pass".
+ */
+ public BasicAuthHeaderProvider(String credentials) {
+ this.credentials = credentials;
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param user username.
+ * @param pass password.
+ */
+ public BasicAuthHeaderProvider(String user, String pass) {
+ this(user + ":" + pass);
+ }
+
+ /**
+ * Return a Header object representing an Authentication header for HTTP
+ * Basic.
+ */
+ @Override
+ public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) {
+ Credentials creds = new UsernamePasswordCredentials(credentials);
+
+ // This must be UTF-8 to generate the same Basic Auth headers as desktop for non-ASCII passwords.
+ return BasicScheme.authenticate(creds, "UTF-8", false);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BearerAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BearerAuthHeaderProvider.java
new file mode 100644
index 000000000..d142d50d9
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BearerAuthHeaderProvider.java
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+/**
+ * An <code>AuthHeaderProvider</code> that returns an Authorization header for
+ * Bearer tokens in the format expected by a Mozilla Firefox Accounts Profile Server.
+ * <p>
+ * See <a href="https://github.com/mozilla/fxa-profile-server/blob/master/docs/API.md">https://github.com/mozilla/fxa-profile-server/blob/master/docs/API.md</a>.
+ */
+public class BearerAuthHeaderProvider extends AbstractBearerTokenAuthHeaderProvider {
+ public BearerAuthHeaderProvider(String token) {
+ super(token);
+ }
+
+ @Override
+ protected String getPrefix() {
+ return "Bearer";
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BrowserIDAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BrowserIDAuthHeaderProvider.java
new file mode 100644
index 000000000..5004673b3
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BrowserIDAuthHeaderProvider.java
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+/**
+ * An <code>AuthHeaderProvider</code> that returns an Authorization header for
+ * BrowserID assertions in the format expected by a Mozilla Services Token
+ * Server.
+ * <p>
+ * See <a href="http://docs.services.mozilla.com/token/apis.html">http://docs.services.mozilla.com/token/apis.html</a>.
+ */
+public class BrowserIDAuthHeaderProvider extends AbstractBearerTokenAuthHeaderProvider {
+ public BrowserIDAuthHeaderProvider(String assertion) {
+ super(assertion);
+ }
+
+ @Override
+ protected String getPrefix() {
+ return "BrowserID";
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ConnectionMonitorThread.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ConnectionMonitorThread.java
new file mode 100644
index 000000000..1a2011771
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ConnectionMonitorThread.java
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+/**
+ * Every <code>REAP_INTERVAL</code> milliseconds, wake up
+ * and expire any connections that need cleaning up.
+ *
+ * When we're told to shut down, take the connection manager
+ * with us.
+ */
+public class ConnectionMonitorThread extends Thread {
+ private static final long REAP_INTERVAL = 5000; // 5 seconds.
+ private static final String LOG_TAG = "ConnectionMonitorThread";
+
+ private volatile boolean stopping;
+
+ @Override
+ public void run() {
+ try {
+ while (!stopping) {
+ synchronized (this) {
+ wait(REAP_INTERVAL);
+ BaseResource.closeExpiredConnections();
+ }
+ }
+ } catch (InterruptedException e) {
+ Logger.trace(LOG_TAG, "Interrupted.");
+ }
+ BaseResource.shutdownConnectionManager();
+ }
+
+ public void shutdown() {
+ Logger.debug(LOG_TAG, "ConnectionMonitorThread told to shut down.");
+ stopping = true;
+ synchronized (this) {
+ notifyAll();
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/GzipNonChunkedCompressingEntity.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/GzipNonChunkedCompressingEntity.java
new file mode 100644
index 000000000..1e238c022
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/GzipNonChunkedCompressingEntity.java
@@ -0,0 +1,92 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.client.entity.GzipCompressingEntity;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Wrapping entity that compresses content when {@link #writeTo writing}.
+ *
+ * This differs from {@link GzipCompressingEntity} in that it does not chunk
+ * the sent data, therefore replacing the "Transfer-Encoding" HTTP header with
+ * the "Content-Length" header required by some servers.
+ *
+ * However, to measure the content length, the gzipped content will be temporarily
+ * stored in memory so be careful what content you send!
+ */
+public class GzipNonChunkedCompressingEntity extends GzipCompressingEntity {
+ final int MAX_BUFFER_SIZE_BYTES = 10 * 1000 * 1000; // 10 MB.
+
+ private byte[] gzippedContent;
+
+ public GzipNonChunkedCompressingEntity(final HttpEntity entity) {
+ super(entity);
+ }
+
+ /**
+ * @return content length for gzipped content or -1 if there is an error
+ */
+ @Override
+ public long getContentLength() {
+ try {
+ initBuffer();
+ } catch (final IOException e) {
+ // GzipCompressingEntity always returns -1 in which case a 'Content-Length' header is omitted.
+ // Presumably, without it the request will fail (either client-side or server-side).
+ return -1;
+ }
+ return gzippedContent.length;
+ }
+
+ @Override
+ public boolean isChunked() {
+ // "Content-Length" & chunked encoding are mutually exclusive:
+ // https://en.wikipedia.org/wiki/Chunked_transfer_encoding
+ return false;
+ }
+
+ @Override
+ public InputStream getContent() throws IOException {
+ initBuffer();
+ return new ByteArrayInputStream(gzippedContent);
+ }
+
+ @Override
+ public void writeTo(final OutputStream outstream) throws IOException {
+ initBuffer();
+ outstream.write(gzippedContent);
+ }
+
+ private void initBuffer() throws IOException {
+ if (gzippedContent != null) {
+ return;
+ }
+
+ final long unzippedContentLength = wrappedEntity.getContentLength();
+ if (unzippedContentLength > MAX_BUFFER_SIZE_BYTES) {
+ throw new IOException(
+ "Wrapped entity content length, " + unzippedContentLength + " bytes, exceeds max: " + MAX_BUFFER_SIZE_BYTES);
+ }
+
+ // The buffer size needed by the gzipped content should be smaller than this,
+ // but it's more efficient just to allocate one larger buffer than allocate
+ // twice if the gzipped content is too large for the default buffer.
+ final ByteArrayOutputStream s = new ByteArrayOutputStream((int) unzippedContentLength);
+ try {
+ super.writeTo(s);
+ } finally {
+ s.close();
+ }
+
+ gzippedContent = s.toByteArray();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HMACAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HMACAuthHeaderProvider.java
new file mode 100644
index 000000000..5314d345b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HMACAuthHeaderProvider.java
@@ -0,0 +1,257 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.Utils;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+import ch.boye.httpclientandroidlib.message.BasicHeader;
+import ch.boye.httpclientandroidlib.protocol.BasicHttpContext;
+
+/**
+ * An <code>AuthHeaderProvider</code> that returns an Authorization header for
+ * HMAC-SHA1-signed requests in the format expected by Mozilla Services
+ * identity-attached services and specified by the MAC Authentication spec, available at
+ * <a href="https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac">https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac</a>.
+ * <p>
+ * See <a href="https://wiki.mozilla.org/Services/Sagrada/ServiceClientFlow#Access">https://wiki.mozilla.org/Services/Sagrada/ServiceClientFlow#Access</a>.
+ */
+public class HMACAuthHeaderProvider implements AuthHeaderProvider {
+ public static final String LOG_TAG = "HMACAuthHeaderProvider";
+
+ public static final int NONCE_LENGTH_IN_BYTES = 8;
+
+ public static final String HMAC_SHA1_ALGORITHM = "hmacSHA1";
+
+ public final String identifier;
+ public final String key;
+
+ public HMACAuthHeaderProvider(String identifier, String key) {
+ // Validate identifier string. From the MAC Authentication spec:
+ // id = "id" "=" string-value
+ // string-value = ( <"> plain-string <"> ) / plain-string
+ // plain-string = 1*( %x20-21 / %x23-5B / %x5D-7E )
+ // We add quotes around the id string, so input identifier must be a plain-string.
+ if (identifier == null) {
+ throw new IllegalArgumentException("identifier must not be null.");
+ }
+ if (!isPlainString(identifier)) {
+ throw new IllegalArgumentException("identifier must be a plain-string.");
+ }
+
+ if (key == null) {
+ throw new IllegalArgumentException("key must not be null.");
+ }
+
+ this.identifier = identifier;
+ this.key = key;
+ }
+
+ @Override
+ public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) throws GeneralSecurityException {
+ long timestamp = System.currentTimeMillis() / 1000;
+ String nonce = Base64.encodeBase64String(Utils.generateRandomBytes(NONCE_LENGTH_IN_BYTES));
+ String extra = "";
+
+ try {
+ return getAuthHeader(request, context, client, timestamp, nonce, extra);
+ } catch (InvalidKeyException | NoSuchAlgorithmException | UnsupportedEncodingException e) {
+ // We lie a little and make every exception a GeneralSecurityException.
+ throw new GeneralSecurityException(e);
+ }
+ }
+
+ /**
+ * Test if input is a <code>plain-string</code>.
+ * <p>
+ * A plain-string is defined by the MAC Authentication spec as
+ * <code>plain-string = 1*( %x20-21 / %x23-5B / %x5D-7E )</code>.
+ *
+ * @param input
+ * as a String of "US-ASCII" bytes.
+ * @return true if input is a <code>plain-string</code>; false otherwise.
+ * @throws UnsupportedEncodingException
+ */
+ protected static boolean isPlainString(String input) {
+ if (input == null || input.length() == 0) {
+ return false;
+ }
+
+ byte[] bytes;
+ try {
+ bytes = input.getBytes("US-ASCII");
+ } catch (UnsupportedEncodingException e) {
+ // Should never happen.
+ Logger.warn(LOG_TAG, "Got exception in isPlainString; returning false.", e);
+ return false;
+ }
+
+ for (byte b : bytes) {
+ if ((0x20 <= b && b <= 0x21) || (0x23 <= b && b <= 0x5B) || (0x5D <= b && b <= 0x7E)) {
+ continue;
+ }
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Helper function that generates an HTTP Authorization header given
+ * additional MAC Authentication specific data.
+ *
+ * @throws UnsupportedEncodingException
+ * @throws NoSuchAlgorithmException
+ * @throws InvalidKeyException
+ */
+ protected Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client,
+ long timestamp, String nonce, String extra)
+ throws UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException {
+ // Validate timestamp. From the MAC Authentication spec:
+ // timestamp = 1*DIGIT
+ // This is equivalent to timestamp >= 0.
+ if (timestamp < 0) {
+ throw new IllegalArgumentException("timestamp must contain only [0-9].");
+ }
+
+ // Validate nonce string. From the MAC Authentication spec:
+ // nonce = "nonce" "=" string-value
+ // string-value = ( <"> plain-string <"> ) / plain-string
+ // plain-string = 1*( %x20-21 / %x23-5B / %x5D-7E )
+ // We add quotes around the nonce string, so input nonce must be a plain-string.
+ if (nonce == null) {
+ throw new IllegalArgumentException("nonce must not be null.");
+ }
+ if (nonce.length() == 0) {
+ throw new IllegalArgumentException("nonce must not be empty.");
+ }
+ if (!isPlainString(nonce)) {
+ throw new IllegalArgumentException("nonce must be a plain-string.");
+ }
+
+ // Validate extra string. From the MAC Authentication spec:
+ // ext = "ext" "=" string-value
+ // string-value = ( <"> plain-string <"> ) / plain-string
+ // plain-string = 1*( %x20-21 / %x23-5B / %x5D-7E )
+ // We add quotes around the extra string, so input extra must be a plain-string.
+ // We break the spec by allowing ext to be an empty string, i.e. to match 0*(...).
+ if (extra == null) {
+ throw new IllegalArgumentException("extra must not be null.");
+ }
+ if (extra.length() > 0 && !isPlainString(extra)) {
+ throw new IllegalArgumentException("extra must be a plain-string.");
+ }
+
+ String requestString = getRequestString(request, timestamp, nonce, extra);
+ String macString = getSignature(requestString, this.key);
+
+ String h = "MAC id=\"" + this.identifier + "\", " +
+ "ts=\"" + timestamp + "\", " +
+ "nonce=\"" + nonce + "\", " +
+ "mac=\"" + macString + "\"";
+
+ if (extra != null) {
+ h += ", ext=\"" + extra + "\"";
+ }
+
+ Header header = new BasicHeader("Authorization", h);
+
+ return header;
+ }
+
+ protected static byte[] sha1(byte[] message, byte[] key)
+ throws NoSuchAlgorithmException, InvalidKeyException {
+
+ SecretKeySpec keySpec = new SecretKeySpec(key, HMAC_SHA1_ALGORITHM);
+
+ Mac hasher = Mac.getInstance(HMAC_SHA1_ALGORITHM);
+ hasher.init(keySpec);
+ hasher.update(message);
+
+ byte[] hmac = hasher.doFinal();
+
+ return hmac;
+ }
+
+ /**
+ * Sign an HMAC request string.
+ *
+ * @param requestString to sign.
+ * @param key as <code>String</code>.
+ * @return signature as base-64 encoded string.
+ * @throws InvalidKeyException
+ * @throws NoSuchAlgorithmException
+ * @throws UnsupportedEncodingException
+ */
+ protected static String getSignature(String requestString, String key)
+ throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
+ String macString = Base64.encodeBase64String(sha1(requestString.getBytes("UTF-8"), key.getBytes("UTF-8")));
+
+ return macString;
+ }
+
+ /**
+ * Generate an HMAC request string.
+ * <p>
+ * This method trusts its inputs to be valid as per the MAC Authentication spec.
+ *
+ * @param request HTTP request.
+ * @param timestamp to use.
+ * @param nonce to use.
+ * @param extra to use.
+ * @return request string.
+ */
+ protected static String getRequestString(HttpUriRequest request, long timestamp, String nonce, String extra) {
+ String method = request.getMethod().toUpperCase();
+
+ URI uri = request.getURI();
+ String host = uri.getHost();
+
+ String path = uri.getRawPath();
+ if (uri.getRawQuery() != null) {
+ path += "?";
+ path += uri.getRawQuery();
+ }
+ if (uri.getRawFragment() != null) {
+ path += "#";
+ path += uri.getRawFragment();
+ }
+
+ int port = uri.getPort();
+ String scheme = uri.getScheme();
+ if (port != -1) {
+ } else if ("http".equalsIgnoreCase(scheme)) {
+ port = 80;
+ } else if ("https".equalsIgnoreCase(scheme)) {
+ port = 443;
+ } else {
+ throw new IllegalArgumentException("Unsupported URI scheme: " + scheme + ".");
+ }
+
+ String requestString = timestamp + "\n" +
+ nonce + "\n" +
+ method + "\n" +
+ path + "\n" +
+ host + "\n" +
+ port + "\n" +
+ extra + "\n";
+
+ return requestString;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HandleProgressException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HandleProgressException.java
new file mode 100644
index 000000000..27ec74b66
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HandleProgressException.java
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class HandleProgressException extends SyncException {
+ private static final long serialVersionUID = -4444933937013161059L;
+
+ public HandleProgressException(Exception ex) {
+ super(ex);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HawkAuthHeaderProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HawkAuthHeaderProvider.java
new file mode 100644
index 000000000..2bdd5604a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HawkAuthHeaderProvider.java
@@ -0,0 +1,403 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Locale;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.Utils;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.HttpEntityEnclosingRequest;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+import ch.boye.httpclientandroidlib.message.BasicHeader;
+import ch.boye.httpclientandroidlib.protocol.BasicHttpContext;
+
+/**
+ * An <code>AuthHeaderProvider</code> that returns an Authorization header for
+ * Hawk: <a href="https://github.com/hueniverse/hawk">https://github.com/hueniverse/hawk</a>.
+ *
+ * Hawk is an HTTP authentication scheme using a message authentication code
+ * (MAC) algorithm to provide partial HTTP request cryptographic verification.
+ * Hawk is the successor to the HMAC authentication scheme.
+ */
+public class HawkAuthHeaderProvider implements AuthHeaderProvider {
+ public static final String LOG_TAG = HawkAuthHeaderProvider.class.getSimpleName();
+
+ public static final int HAWK_HEADER_VERSION = 1;
+
+ protected static final int NONCE_LENGTH_IN_BYTES = 8;
+ protected static final String HMAC_SHA256_ALGORITHM = "hmacSHA256";
+
+ protected final String id;
+ protected final byte[] key;
+ protected final boolean includePayloadHash;
+ protected final long skewSeconds;
+
+ /**
+ * Create a Hawk Authorization header provider.
+ * <p>
+ * Hawk specifies no mechanism by which a client receives an
+ * identifier-and-key pair from the server.
+ * <p>
+ * Hawk requests can include a payload verification hash with requests that
+ * enclose an entity (PATCH, POST, and PUT requests). <b>You should default
+ * to including the payload verification hash<b> unless you have a good reason
+ * not to -- the server can always ignore payload verification hashes provided
+ * by the client.
+ *
+ * @param id
+ * to name requests with.
+ * @param key
+ * to sign request with.
+ *
+ * @param includePayloadHash
+ * true if payload verification hash should be included in signed
+ * request header. See <a href="https://github.com/hueniverse/hawk#payload-validation">https://github.com/hueniverse/hawk#payload-validation</a>.
+ *
+ * @param skewSeconds
+ * a number of seconds by which to skew the current time when
+ * computing a header.
+ */
+ public HawkAuthHeaderProvider(String id, byte[] key, boolean includePayloadHash, long skewSeconds) {
+ if (id == null) {
+ throw new IllegalArgumentException("id must not be null");
+ }
+ if (key == null) {
+ throw new IllegalArgumentException("key must not be null");
+ }
+ this.id = id;
+ this.key = key;
+ this.includePayloadHash = includePayloadHash;
+ this.skewSeconds = skewSeconds;
+ }
+
+ /**
+ * @return the current time in milliseconds.
+ */
+ @SuppressWarnings("static-method")
+ protected long now() {
+ return System.currentTimeMillis();
+ }
+
+ /**
+ * @return the current time in seconds, adjusted for skew. This should
+ * approximate the server's timestamp.
+ */
+ protected long getTimestampSeconds() {
+ return (now() / 1000) + skewSeconds;
+ }
+
+ @Override
+ public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) throws GeneralSecurityException {
+ long timestamp = getTimestampSeconds();
+ String nonce = Base64.encodeBase64String(Utils.generateRandomBytes(NONCE_LENGTH_IN_BYTES));
+ String extra = "";
+
+ try {
+ return getAuthHeader(request, context, client, timestamp, nonce, extra, this.includePayloadHash);
+ } catch (Exception e) {
+ // We lie a little and make every exception a GeneralSecurityException.
+ throw new GeneralSecurityException(e);
+ }
+ }
+
+ /**
+ * Helper function that generates an HTTP Authorization: Hawk header given
+ * additional Hawk specific data.
+ *
+ * @throws NoSuchAlgorithmException
+ * @throws InvalidKeyException
+ * @throws IOException
+ */
+ protected Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client,
+ long timestamp, String nonce, String extra, boolean includePayloadHash)
+ throws InvalidKeyException, NoSuchAlgorithmException, IOException {
+ if (timestamp < 0) {
+ throw new IllegalArgumentException("timestamp must contain only [0-9].");
+ }
+
+ if (nonce == null) {
+ throw new IllegalArgumentException("nonce must not be null.");
+ }
+ if (nonce.length() == 0) {
+ throw new IllegalArgumentException("nonce must not be empty.");
+ }
+
+ String payloadHash = null;
+ if (includePayloadHash) {
+ payloadHash = getPayloadHashString(request);
+ } else {
+ Logger.debug(LOG_TAG, "Configured to not include payload hash for this request.");
+ }
+
+ String app = null;
+ String dlg = null;
+ String requestString = getRequestString(request, "header", timestamp, nonce, payloadHash, extra, app, dlg);
+ String macString = getSignature(requestString.getBytes("UTF-8"), this.key);
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("Hawk id=\"");
+ sb.append(this.id);
+ sb.append("\", ");
+ sb.append("ts=\"");
+ sb.append(timestamp);
+ sb.append("\", ");
+ sb.append("nonce=\"");
+ sb.append(nonce);
+ sb.append("\", ");
+ if (payloadHash != null) {
+ sb.append("hash=\"");
+ sb.append(payloadHash);
+ sb.append("\", ");
+ }
+ if (extra != null && extra.length() > 0) {
+ sb.append("ext=\"");
+ sb.append(escapeExtraHeaderAttribute(extra));
+ sb.append("\", ");
+ }
+ sb.append("mac=\"");
+ sb.append(macString);
+ sb.append("\"");
+
+ return new BasicHeader("Authorization", sb.toString());
+ }
+
+ /**
+ * Get the payload verification hash for the given request, if possible.
+ * <p>
+ * Returns null if the request does not enclose an entity (is not an HTTP
+ * PATCH, POST, or PUT). Throws if the payload verification hash cannot be
+ * computed.
+ *
+ * @param request
+ * to compute hash for.
+ * @return verification hash, or null if the request does not enclose an entity.
+ * @throws IllegalArgumentException if the request does not enclose a valid non-null entity.
+ * @throws UnsupportedEncodingException
+ * @throws NoSuchAlgorithmException
+ * @throws IOException
+ */
+ protected static String getPayloadHashString(HttpRequestBase request)
+ throws UnsupportedEncodingException, NoSuchAlgorithmException, IOException, IllegalArgumentException {
+ final boolean shouldComputePayloadHash = request instanceof HttpEntityEnclosingRequest;
+ if (!shouldComputePayloadHash) {
+ Logger.debug(LOG_TAG, "Not computing payload verification hash for non-enclosing request.");
+ return null;
+ }
+ if (!(request instanceof HttpEntityEnclosingRequest)) {
+ throw new IllegalArgumentException("Cannot compute payload verification hash for enclosing request without an entity");
+ }
+ final HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity();
+ if (entity == null) {
+ throw new IllegalArgumentException("Cannot compute payload verification hash for enclosing request with a null entity");
+ }
+ return Base64.encodeBase64String(getPayloadHash(entity));
+ }
+
+ /**
+ * Escape the user-provided extra string for the ext="" header attribute.
+ * <p>
+ * Hawk escapes the header ext="" attribute differently than it does the extra
+ * line in the normalized request string.
+ * <p>
+ * See <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/browser.js#L385">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/browser.js#L385</a>.
+ *
+ * @param extra to escape.
+ * @return extra escaped for the ext="" header attribute.
+ */
+ protected static String escapeExtraHeaderAttribute(String extra) {
+ return extra.replaceAll("\\\\", "\\\\").replaceAll("\"", "\\\"");
+ }
+
+ /**
+ * Escape the user-provided extra string for inserting into the normalized
+ * request string.
+ * <p>
+ * Hawk escapes the header ext="" attribute differently than it does the extra
+ * line in the normalized request string.
+ * <p>
+ * See <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L67">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L67</a>.
+ *
+ * @param extra to escape.
+ * @return extra escaped for the normalized request string.
+ */
+ protected static String escapeExtraString(String extra) {
+ return extra.replaceAll("\\\\", "\\\\").replaceAll("\n", "\\n");
+ }
+
+ /**
+ * Return the content type with no parameters (pieces following ;).
+ *
+ * @param contentTypeHeader to interrogate.
+ * @return base content type.
+ */
+ protected static String getBaseContentType(Header contentTypeHeader) {
+ if (contentTypeHeader == null) {
+ throw new IllegalArgumentException("contentTypeHeader must not be null.");
+ }
+ String contentType = contentTypeHeader.getValue();
+ if (contentType == null) {
+ throw new IllegalArgumentException("contentTypeHeader value must not be null.");
+ }
+ int index = contentType.indexOf(";");
+ if (index < 0) {
+ return contentType.trim();
+ }
+ return contentType.substring(0, index).trim();
+ }
+
+ /**
+ * Generate the SHA-256 hash of a normalized Hawk payload generated from an
+ * HTTP entity.
+ * <p>
+ * <b>Warning:</b> the entity <b>must</b> be repeatable. If it is not, this
+ * code throws an <code>IllegalArgumentException</code>.
+ * <p>
+ * This is under-specified; the code here was reverse engineered from the code
+ * at
+ * <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L81">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L81</a>.
+ * @param entity to normalize and hash.
+ * @return hash.
+ * @throws IllegalArgumentException if entity is not repeatable.
+ */
+ protected static byte[] getPayloadHash(HttpEntity entity) throws UnsupportedEncodingException, IOException, NoSuchAlgorithmException {
+ if (!entity.isRepeatable()) {
+ throw new IllegalArgumentException("entity must be repeatable");
+ }
+ final MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ digest.update(("hawk." + HAWK_HEADER_VERSION + ".payload\n").getBytes("UTF-8"));
+ digest.update(getBaseContentType(entity.getContentType()).getBytes("UTF-8"));
+ digest.update("\n".getBytes("UTF-8"));
+ InputStream stream = entity.getContent();
+ try {
+ int numRead;
+ byte[] buffer = new byte[4096];
+ while (-1 != (numRead = stream.read(buffer))) {
+ if (numRead > 0) {
+ digest.update(buffer, 0, numRead);
+ }
+ }
+ digest.update("\n".getBytes("UTF-8")); // Trailing newline is specified by Hawk.
+ return digest.digest();
+ } finally {
+ stream.close();
+ }
+ }
+
+ /**
+ * Generate a normalized Hawk request string. This is under-specified; the
+ * code here was reverse engineered from the code at
+ * <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L55">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L55</a>.
+ * <p>
+ * This method trusts its inputs to be valid.
+ */
+ protected static String getRequestString(HttpUriRequest request, String type, long timestamp, String nonce, String hash, String extra, String app, String dlg) {
+ String method = request.getMethod().toUpperCase(Locale.US);
+
+ URI uri = request.getURI();
+ String host = uri.getHost();
+
+ String path = uri.getRawPath();
+ if (uri.getRawQuery() != null) {
+ path += "?";
+ path += uri.getRawQuery();
+ }
+ if (uri.getRawFragment() != null) {
+ path += "#";
+ path += uri.getRawFragment();
+ }
+
+ int port = uri.getPort();
+ String scheme = uri.getScheme();
+ if (port != -1) {
+ } else if ("http".equalsIgnoreCase(scheme)) {
+ port = 80;
+ } else if ("https".equalsIgnoreCase(scheme)) {
+ port = 443;
+ } else {
+ throw new IllegalArgumentException("Unsupported URI scheme: " + scheme + ".");
+ }
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("hawk.");
+ sb.append(HAWK_HEADER_VERSION);
+ sb.append('.');
+ sb.append(type);
+ sb.append('\n');
+ sb.append(timestamp);
+ sb.append('\n');
+ sb.append(nonce);
+ sb.append('\n');
+ sb.append(method);
+ sb.append('\n');
+ sb.append(path);
+ sb.append('\n');
+ sb.append(host);
+ sb.append('\n');
+ sb.append(port);
+ sb.append('\n');
+ if (hash != null) {
+ sb.append(hash);
+ }
+ sb.append("\n");
+ if (extra != null && extra.length() > 0) {
+ sb.append(escapeExtraString(extra));
+ }
+ sb.append("\n");
+ if (app != null) {
+ sb.append(app);
+ sb.append("\n");
+ if (dlg != null) {
+ sb.append(dlg);
+ }
+ sb.append("\n");
+ }
+
+ return sb.toString();
+ }
+
+ protected static byte[] hmacSha256(byte[] message, byte[] key)
+ throws NoSuchAlgorithmException, InvalidKeyException {
+
+ SecretKeySpec keySpec = new SecretKeySpec(key, HMAC_SHA256_ALGORITHM);
+
+ Mac hasher = Mac.getInstance(HMAC_SHA256_ALGORITHM);
+ hasher.init(keySpec);
+ hasher.update(message);
+
+ return hasher.doFinal();
+ }
+
+ /**
+ * Sign a Hawk request string.
+ *
+ * @param requestString to sign.
+ * @param key as <code>String</code>.
+ * @return signature as base-64 encoded string.
+ * @throws InvalidKeyException
+ * @throws NoSuchAlgorithmException
+ * @throws UnsupportedEncodingException
+ */
+ protected static String getSignature(byte[] requestString, byte[] key)
+ throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
+ return Base64.encodeBase64String(hmacSha256(requestString, key));
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HttpResponseObserver.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HttpResponseObserver.java
new file mode 100644
index 000000000..24b37a0e6
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/HttpResponseObserver.java
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
+
+public interface HttpResponseObserver {
+ /**
+ * Observe an HTTP response.
+ * @param request
+ * The <code>HttpUriRequest<code> that elicited the response.
+ *
+ * @param response
+ * The <code>HttpResponse</code> to observe.
+ */
+ public void observeHttpResponse(HttpUriRequest request, HttpResponse response);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/MozResponse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/MozResponse.java
new file mode 100644
index 000000000..3f76f929f
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/MozResponse.java
@@ -0,0 +1,225 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.Scanner;
+
+import org.json.simple.JSONArray;
+import org.json.simple.parser.JSONParser;
+import org.json.simple.parser.ParseException;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonArrayJSONException;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.HttpStatus;
+import ch.boye.httpclientandroidlib.impl.cookie.DateParseException;
+import ch.boye.httpclientandroidlib.impl.cookie.DateUtils;
+
+public class MozResponse {
+ private static final String LOG_TAG = "MozResponse";
+
+ private static final String HEADER_RETRY_AFTER = "retry-after";
+
+ protected HttpResponse response;
+ private String body = null;
+
+ public HttpResponse httpResponse() {
+ return this.response;
+ }
+
+ public int getStatusCode() {
+ return this.response.getStatusLine().getStatusCode();
+ }
+
+ public boolean wasSuccessful() {
+ return this.getStatusCode() == 200;
+ }
+
+ public boolean isInvalidAuthentication() {
+ return this.getStatusCode() == HttpStatus.SC_UNAUTHORIZED;
+ }
+
+ /**
+ * Fetch the content type of the HTTP response body.
+ *
+ * @return a <code>Header</code> instance, or <code>null</code> if there was
+ * no body or no valid Content-Type.
+ */
+ public Header getContentType() {
+ HttpEntity entity = this.response.getEntity();
+ if (entity == null) {
+ return null;
+ }
+ return entity.getContentType();
+ }
+
+ private static boolean missingHeader(String value) {
+ return value == null ||
+ value.trim().length() == 0;
+ }
+
+ public String body() throws IllegalStateException, IOException {
+ if (body != null) {
+ return body;
+ }
+ final HttpEntity entity = this.response.getEntity();
+ if (entity == null) {
+ body = null;
+ return null;
+ }
+
+ InputStreamReader is = new InputStreamReader(entity.getContent());
+ // Oh, Java, you are so evil.
+ body = new Scanner(is).useDelimiter("\\A").next();
+ return body;
+ }
+
+ /**
+ * Return the body as a <b>non-null</b> <code>ExtendedJSONObject</code>.
+ *
+ * @return A non-null <code>ExtendedJSONObject</code>.
+ *
+ * @throws IllegalStateException
+ * @throws IOException
+ * @throws NonObjectJSONException
+ */
+ public ExtendedJSONObject jsonObjectBody() throws IllegalStateException, IOException, NonObjectJSONException {
+ if (body != null) {
+ // Do it from the cached String.
+ return new ExtendedJSONObject(body);
+ }
+
+ HttpEntity entity = this.response.getEntity();
+ if (entity == null) {
+ throw new IOException("no entity");
+ }
+
+ InputStream content = entity.getContent();
+ try {
+ Reader in = new BufferedReader(new InputStreamReader(content, "UTF-8"));
+ return new ExtendedJSONObject(in);
+ } finally {
+ content.close();
+ }
+ }
+
+ public JSONArray jsonArrayBody() throws NonArrayJSONException, IOException {
+ final JSONParser parser = new JSONParser();
+ try {
+ if (body != null) {
+ // Do it from the cached String.
+ return (JSONArray) parser.parse(body);
+ }
+
+ final HttpEntity entity = this.response.getEntity();
+ if (entity == null) {
+ throw new IOException("no entity");
+ }
+
+ final InputStream content = entity.getContent();
+ final Reader in = new BufferedReader(new InputStreamReader(content, "UTF-8"));
+ try {
+ return (JSONArray) parser.parse(in);
+ } finally {
+ in.close();
+ }
+ } catch (ClassCastException | ParseException e) {
+ NonArrayJSONException exception = new NonArrayJSONException("value must be a json array");
+ exception.initCause(e);
+ throw exception;
+ }
+ }
+
+ protected boolean hasHeader(String h) {
+ return this.response.containsHeader(h);
+ }
+
+ public MozResponse(HttpResponse res) {
+ response = res;
+ }
+
+ protected String getNonMissingHeader(String h) {
+ if (!this.hasHeader(h)) {
+ return null;
+ }
+
+ final Header header = this.response.getFirstHeader(h);
+ final String value = header.getValue();
+ if (missingHeader(value)) {
+ Logger.warn(LOG_TAG, h + " header present but empty.");
+ return null;
+ }
+ return value;
+ }
+
+ protected long getLongHeader(String h) throws NumberFormatException {
+ final String value = getNonMissingHeader(h);
+ if (value == null) {
+ return -1L;
+ }
+ return Long.parseLong(value, 10);
+ }
+
+ protected int getIntegerHeader(String h) throws NumberFormatException {
+ final String value = getNonMissingHeader(h);
+ if (value == null) {
+ return -1;
+ }
+ return Integer.parseInt(value, 10);
+ }
+
+ /**
+ * @return A number of seconds, or -1 if the 'Retry-After' header was not present.
+ */
+ public int retryAfterInSeconds() throws NumberFormatException {
+ final String retryAfter = getNonMissingHeader(HEADER_RETRY_AFTER);
+ if (retryAfter == null) {
+ return -1;
+ }
+
+ try {
+ return Integer.parseInt(retryAfter, 10);
+ } catch (NumberFormatException e) {
+ // Fall through to try date format.
+ }
+
+ try {
+ final long then = DateUtils.parseDate(retryAfter).getTime();
+ final long now = System.currentTimeMillis();
+ return (int)((then - now) / 1000); // Convert milliseconds to seconds.
+ } catch (DateParseException e) {
+ Logger.warn(LOG_TAG, "Retry-After header neither integer nor date: " + retryAfter);
+ return -1;
+ }
+ }
+
+ /**
+ * @return A number of seconds, or -1 if the 'Backoff' header was not
+ * present.
+ */
+ public int backoffInSeconds() throws NumberFormatException {
+ return this.getIntegerHeader("backoff");
+ }
+
+ public void logResponseBody(final String logTag) {
+ if (!Logger.LOG_PERSONAL_INFORMATION) {
+ return;
+ }
+ try {
+ Logger.pii(logTag, "Response body: " + body());
+ } catch (Throwable e) {
+ Logger.debug(logTag, "No response body.");
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/Resource.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/Resource.java
new file mode 100644
index 000000000..ab7b98aff
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/Resource.java
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.net.URI;
+
+import ch.boye.httpclientandroidlib.HttpEntity;
+
+public interface Resource {
+ public abstract URI getURI();
+ public abstract String getURIString();
+ public abstract String getHostname();
+ public abstract void get();
+ public abstract void delete();
+ public abstract void post(HttpEntity body);
+ public abstract void patch(HttpEntity body);
+ public abstract void put(HttpEntity body);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ResourceDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ResourceDelegate.java
new file mode 100644
index 000000000..0dea9432b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/ResourceDelegate.java
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+
+/**
+ * ResourceDelegate implementers must ensure that HTTP responses
+ * are fully consumed to ensure that connections are returned to
+ * the pool:
+ *
+ * EntityUtils.consume(entity);
+ * @author rnewman
+ *
+ */
+public interface ResourceDelegate {
+ // Request augmentation.
+ AuthHeaderProvider getAuthHeaderProvider();
+ void addHeaders(HttpRequestBase request, DefaultHttpClient client);
+
+ /**
+ * The value of the User-Agent header to include with the request.
+ *
+ * @return User-Agent header value; null means do not set User-Agent header.
+ */
+ public String getUserAgent();
+
+ // Response handling.
+
+ /**
+ * Override this to handle an HttpResponse.
+ *
+ * ResourceDelegate implementers <b>must</b> ensure that HTTP responses are
+ * fully consumed to ensure that connections are returned to the pool, for
+ * example by calling <code>EntityUtils.consume(response.getEntity())</code>.
+ */
+ void handleHttpResponse(HttpResponse response);
+ void handleHttpProtocolException(ClientProtocolException e);
+ void handleHttpIOException(IOException e);
+
+ // During preparation.
+ void handleTransportException(GeneralSecurityException e);
+
+ // Connection parameters.
+ int connectionTimeout();
+ int socketTimeout();
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SRPConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SRPConstants.java
new file mode 100644
index 000000000..5dfe660ef
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SRPConstants.java
@@ -0,0 +1,174 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.math.BigInteger;
+
+/**
+ * SRP Group Parameters from
+ * <a href="http://tools.ietf.org/html/rfc5054#appendix-A">Appendix A of RFC 5054</a>.
+ *
+ * The 1024-, 1536-, and 2048-bit groups are taken from software
+ * developed by Tom Wu and Eugene Jhong for the Stanford SRP
+ * distribution, and subsequently proven to be prime. The larger primes
+ * are taken from [MODP], but generators have been calculated that are
+ * primitive roots of N, unlike the generators in [MODP].
+ *
+ * The 1024-bit and 1536-bit groups <b>MUST</b> be supported.
+ */
+public class SRPConstants {
+ public static class Parameters {
+ public final BigInteger N;
+ public final BigInteger g;
+ public final int bitLength;
+ public final int byteLength;
+ public final int hexLength;
+
+ protected Parameters(String N, long g) {
+ if (N == null) {
+ throw new IllegalArgumentException("N must not be null");
+ }
+ this.N = new BigInteger(N.replaceAll(" ", ""), 16); // Hex.
+ this.g = BigInteger.valueOf(g);
+ this.hexLength = this.N.toString(16).length();
+ this.byteLength = hexLength / 2;
+ this.bitLength = this.byteLength * 8;
+ }
+ }
+
+ public static final Parameters _1024 = new Parameters("" +
+ "EEAF0AB9 ADB38DD6 9C33F80A FA8FC5E8 60726187 75FF3C0B 9EA2314C" +
+ "9C256576 D674DF74 96EA81D3 383B4813 D692C6E0 E0D5D8E2 50B98BE4" +
+ "8E495C1D 6089DAD1 5DC7D7B4 6154D6B6 CE8EF4AD 69B15D49 82559B29" +
+ "7BCF1885 C529F566 660E57EC 68EDBC3C 05726CC0 2FD4CBF4 976EAA9A" +
+ "FD5138FE 8376435B 9FC61D2F C0EB06E3", 2L);
+
+ public static final Parameters _1536 = new Parameters("" +
+ "9DEF3CAF B939277A B1F12A86 17A47BBB DBA51DF4 99AC4C80 BEEEA961" +
+ "4B19CC4D 5F4F5F55 6E27CBDE 51C6A94B E4607A29 1558903B A0D0F843" +
+ "80B655BB 9A22E8DC DF028A7C EC67F0D0 8134B1C8 B9798914 9B609E0B" +
+ "E3BAB63D 47548381 DBC5B1FC 764E3F4B 53DD9DA1 158BFD3E 2B9C8CF5" +
+ "6EDF0195 39349627 DB2FD53D 24B7C486 65772E43 7D6C7F8C E442734A" +
+ "F7CCB7AE 837C264A E3A9BEB8 7F8A2FE9 B8B5292E 5A021FFF 5E91479E" +
+ "8CE7A28C 2442C6F3 15180F93 499A234D CF76E3FE D135F9BB", 2L);
+
+ public static final Parameters _2048 = new Parameters("" +
+ "AC6BDB41 324A9A9B F166DE5E 1389582F AF72B665 1987EE07 FC319294" +
+ "3DB56050 A37329CB B4A099ED 8193E075 7767A13D D52312AB 4B03310D" +
+ "CD7F48A9 DA04FD50 E8083969 EDB767B0 CF609517 9A163AB3 661A05FB" +
+ "D5FAAAE8 2918A996 2F0B93B8 55F97993 EC975EEA A80D740A DBF4FF74" +
+ "7359D041 D5C33EA7 1D281E44 6B14773B CA97B43A 23FB8016 76BD207A" +
+ "436C6481 F1D2B907 8717461A 5B9D32E6 88F87748 544523B5 24B0D57D" +
+ "5EA77A27 75D2ECFA 032CFBDB F52FB378 61602790 04E57AE6 AF874E73" +
+ "03CE5329 9CCC041C 7BC308D8 2A5698F3 A8D0C382 71AE35F8 E9DBFBB6" +
+ "94B5C803 D89F7AE4 35DE236D 525F5475 9B65E372 FCD68EF2 0FA7111F" +
+ "9E4AFF73", 2L);
+
+ public static final Parameters _3072 = new Parameters("" +
+ "FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08" +
+ "8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B" +
+ "302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9" +
+ "A637ED6B 0BFF5CB6 F406B7ED EE386BFB 5A899FA5 AE9F2411 7C4B1FE6" +
+ "49286651 ECE45B3D C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8" +
+ "FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D" +
+ "670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B E39E772C" +
+ "180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" +
+ "3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D" +
+ "04507A33 A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D" +
+ "B3970F85 A6E1E4C7 ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226" +
+ "1AD2EE6B F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C" +
+ "BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 43DB5BFC" +
+ "E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF", 5L);
+
+ public static final Parameters _4096 = new Parameters("" +
+ "FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08" +
+ "8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B" +
+ "302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9" +
+ "A637ED6B 0BFF5CB6 F406B7ED EE386BFB 5A899FA5 AE9F2411 7C4B1FE6" +
+ "49286651 ECE45B3D C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8" +
+ "FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D" +
+ "670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B E39E772C" +
+ "180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" +
+ "3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D" +
+ "04507A33 A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D" +
+ "B3970F85 A6E1E4C7 ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226" +
+ "1AD2EE6B F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C" +
+ "BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 43DB5BFC" +
+ "E0FD108E 4B82D120 A9210801 1A723C12 A787E6D7 88719A10 BDBA5B26" +
+ "99C32718 6AF4E23C 1A946834 B6150BDA 2583E9CA 2AD44CE8 DBBBC2DB" +
+ "04DE8EF9 2E8EFC14 1FBECAA6 287C5947 4E6BC05D 99B2964F A090C3A2" +
+ "233BA186 515BE7ED 1F612970 CEE2D7AF B81BDD76 2170481C D0069127" +
+ "D5B05AA9 93B4EA98 8D8FDDC1 86FFB7DC 90A6C08F 4DF435C9 34063199" +
+ "FFFFFFFF FFFFFFFF", 5L);
+
+ public static final Parameters _6144 = new Parameters("" +
+ "FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08" +
+ "8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B" +
+ "302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9" +
+ "A637ED6B 0BFF5CB6 F406B7ED EE386BFB 5A899FA5 AE9F2411 7C4B1FE6" +
+ "49286651 ECE45B3D C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8" +
+ "FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D" +
+ "670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B E39E772C" +
+ "180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" +
+ "3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D" +
+ "04507A33 A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D" +
+ "B3970F85 A6E1E4C7 ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226" +
+ "1AD2EE6B F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C" +
+ "BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 43DB5BFC" +
+ "E0FD108E 4B82D120 A9210801 1A723C12 A787E6D7 88719A10 BDBA5B26" +
+ "99C32718 6AF4E23C 1A946834 B6150BDA 2583E9CA 2AD44CE8 DBBBC2DB" +
+ "04DE8EF9 2E8EFC14 1FBECAA6 287C5947 4E6BC05D 99B2964F A090C3A2" +
+ "233BA186 515BE7ED 1F612970 CEE2D7AF B81BDD76 2170481C D0069127" +
+ "D5B05AA9 93B4EA98 8D8FDDC1 86FFB7DC 90A6C08F 4DF435C9 34028492" +
+ "36C3FAB4 D27C7026 C1D4DCB2 602646DE C9751E76 3DBA37BD F8FF9406" +
+ "AD9E530E E5DB382F 413001AE B06A53ED 9027D831 179727B0 865A8918" +
+ "DA3EDBEB CF9B14ED 44CE6CBA CED4BB1B DB7F1447 E6CC254B 33205151" +
+ "2BD7AF42 6FB8F401 378CD2BF 5983CA01 C64B92EC F032EA15 D1721D03" +
+ "F482D7CE 6E74FEF6 D55E702F 46980C82 B5A84031 900B1C9E 59E7C97F" +
+ "BEC7E8F3 23A97A7E 36CC88BE 0F1D45B7 FF585AC5 4BD407B2 2B4154AA" +
+ "CC8F6D7E BF48E1D8 14CC5ED2 0F8037E0 A79715EE F29BE328 06A1D58B" +
+ "B7C5DA76 F550AA3D 8A1FBFF0 EB19CCB1 A313D55C DA56C9EC 2EF29632" +
+ "387FE8D7 6E3C0468 043E8F66 3F4860EE 12BF2D5B 0B7474D6 E694F91E" +
+ "6DCC4024 FFFFFFFF FFFFFFFF", 5L);
+
+ public static final Parameters _8192 = new Parameters("" +
+ "FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08" +
+ "8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B" +
+ "302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9" +
+ "A637ED6B 0BFF5CB6 F406B7ED EE386BFB 5A899FA5 AE9F2411 7C4B1FE6" +
+ "49286651 ECE45B3D C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8" +
+ "FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D" +
+ "670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B E39E772C" +
+ "180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718" +
+ "3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D" +
+ "04507A33 A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D" +
+ "B3970F85 A6E1E4C7 ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226" +
+ "1AD2EE6B F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C" +
+ "BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 43DB5BFC" +
+ "E0FD108E 4B82D120 A9210801 1A723C12 A787E6D7 88719A10 BDBA5B26" +
+ "99C32718 6AF4E23C 1A946834 B6150BDA 2583E9CA 2AD44CE8 DBBBC2DB" +
+ "04DE8EF9 2E8EFC14 1FBECAA6 287C5947 4E6BC05D 99B2964F A090C3A2" +
+ "233BA186 515BE7ED 1F612970 CEE2D7AF B81BDD76 2170481C D0069127" +
+ "D5B05AA9 93B4EA98 8D8FDDC1 86FFB7DC 90A6C08F 4DF435C9 34028492" +
+ "36C3FAB4 D27C7026 C1D4DCB2 602646DE C9751E76 3DBA37BD F8FF9406" +
+ "AD9E530E E5DB382F 413001AE B06A53ED 9027D831 179727B0 865A8918" +
+ "DA3EDBEB CF9B14ED 44CE6CBA CED4BB1B DB7F1447 E6CC254B 33205151" +
+ "2BD7AF42 6FB8F401 378CD2BF 5983CA01 C64B92EC F032EA15 D1721D03" +
+ "F482D7CE 6E74FEF6 D55E702F 46980C82 B5A84031 900B1C9E 59E7C97F" +
+ "BEC7E8F3 23A97A7E 36CC88BE 0F1D45B7 FF585AC5 4BD407B2 2B4154AA" +
+ "CC8F6D7E BF48E1D8 14CC5ED2 0F8037E0 A79715EE F29BE328 06A1D58B" +
+ "B7C5DA76 F550AA3D 8A1FBFF0 EB19CCB1 A313D55C DA56C9EC 2EF29632" +
+ "387FE8D7 6E3C0468 043E8F66 3F4860EE 12BF2D5B 0B7474D6 E694F91E" +
+ "6DBE1159 74A3926F 12FEE5E4 38777CB6 A932DF8C D8BEC4D0 73B931BA" +
+ "3BC832B6 8D9DD300 741FA7BF 8AFC47ED 2576F693 6BA42466 3AAB639C" +
+ "5AE4F568 3423B474 2BF1C978 238F16CB E39D652D E3FDB8BE FC848AD9" +
+ "22222E04 A4037C07 13EB57A8 1A23F0C7 3473FC64 6CEA306B 4BCBC886" +
+ "2F8385DD FA9D4B7F A2C087E8 79683303 ED5BDD3A 062B3CF5 B3A278A6" +
+ "6D2A13F8 3F44F82D DF310EE0 74AB6A36 4597E899 A0255DC1 64F31CC5" +
+ "0846851D F9AB4819 5DED7EA1 B1D510BD 7EE74D73 FAF36BC3 1ECFA268" +
+ "359046F4 EB879F92 4009438B 481C6CD7 889A002E D5EE382B C9190DA6" +
+ "FC026E47 9558E447 5677E9AA 9E3050E2 765694DF C81F56E8 80B96E71" +
+ "60C980DD 98EDD3DF FFFFFFFF FFFFFFFF", 19L);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncResponse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncResponse.java
new file mode 100644
index 000000000..177d7aaba
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncResponse.java
@@ -0,0 +1,157 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import android.support.annotation.Nullable;
+
+import org.mozilla.gecko.sync.Utils;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+
+public class SyncResponse extends MozResponse {
+ public static final String X_WEAVE_BACKOFF = "x-weave-backoff";
+ public static final String X_BACKOFF = "x-backoff";
+ public static final String X_LAST_MODIFIED = "x-last-modified";
+ public static final String X_WEAVE_TIMESTAMP = "x-weave-timestamp";
+ public static final String X_WEAVE_RECORDS = "x-weave-records";
+ public static final String X_WEAVE_QUOTA_REMAINING = "x-weave-quota-remaining";
+ public static final String X_WEAVE_ALERT = "x-weave-alert";
+ public static final String X_WEAVE_NEXT_OFFSET = "x-weave-next-offset";
+
+ public SyncResponse(HttpResponse res) {
+ super(res);
+ }
+
+ /**
+ * @return A number of seconds, or -1 if the 'X-Weave-Backoff' header was not
+ * present.
+ */
+ public int weaveBackoffInSeconds() throws NumberFormatException {
+ return this.getIntegerHeader(X_WEAVE_BACKOFF);
+ }
+
+ /**
+ * @return A number of seconds, or -1 if the 'X-Backoff' header was not
+ * present.
+ */
+ public int xBackoffInSeconds() throws NumberFormatException {
+ return this.getIntegerHeader(X_BACKOFF);
+ }
+
+ /**
+ * Extract a number of seconds, or -1 if none of the specified headers were present.
+ *
+ * @param includeRetryAfter
+ * if <code>true</code>, the Retry-After header is excluded. This is
+ * useful for processing non-error responses where a Retry-After
+ * header would be unexpected.
+ * @return the maximum of the three possible backoff headers, in seconds.
+ */
+ public int totalBackoffInSeconds(boolean includeRetryAfter) {
+ int retryAfterInSeconds = -1;
+ if (includeRetryAfter) {
+ try {
+ retryAfterInSeconds = retryAfterInSeconds();
+ } catch (NumberFormatException e) {
+ }
+ }
+
+ int weaveBackoffInSeconds = -1;
+ try {
+ weaveBackoffInSeconds = weaveBackoffInSeconds();
+ } catch (NumberFormatException e) {
+ }
+
+ int backoffInSeconds = -1;
+ try {
+ backoffInSeconds = xBackoffInSeconds();
+ } catch (NumberFormatException e) {
+ }
+
+ int totalBackoff = Math.max(retryAfterInSeconds, Math.max(backoffInSeconds, weaveBackoffInSeconds));
+ if (totalBackoff < 0) {
+ return -1;
+ } else {
+ return totalBackoff;
+ }
+ }
+
+ /**
+ * @return A number of milliseconds, or -1 if neither the 'Retry-After',
+ * 'X-Backoff', or 'X-Weave-Backoff' header were present.
+ */
+ public long totalBackoffInMilliseconds() {
+ long totalBackoff = totalBackoffInSeconds(true);
+ if (totalBackoff < 0) {
+ return -1;
+ } else {
+ return 1000 * totalBackoff;
+ }
+ }
+
+ public long normalizedWeaveTimestamp() {
+ return normalizedTimestampForHeader(X_WEAVE_TIMESTAMP);
+ }
+
+ /**
+ * Timestamps returned from a Sync server are decimal numbers of seconds,
+ * e.g., 1323393518.04.
+ *
+ * We want milliseconds since epoch.
+ *
+ * @return milliseconds since the epoch, as a long, or -1 if the header
+ * was missing or invalid.
+ */
+ public long normalizedTimestampForHeader(String header) {
+ if (!this.hasHeader(header)) {
+ return -1;
+ }
+
+ return Utils.decimalSecondsToMilliseconds(
+ this.response.getFirstHeader(header).getValue()
+ );
+ }
+
+ public int weaveRecords() throws NumberFormatException {
+ return this.getIntegerHeader(X_WEAVE_RECORDS);
+ }
+
+ public int weaveQuotaRemaining() throws NumberFormatException {
+ return this.getIntegerHeader(X_WEAVE_QUOTA_REMAINING);
+ }
+
+ public String weaveAlert() {
+ return this.getNonMissingHeader(X_WEAVE_ALERT);
+ }
+
+ /**
+ * This header may be sent back with multi-record responses where the request included a limit parameter.
+ * Its presence indicates that the number of available records exceeded the given limit.
+ * The value from this header can be passed back in the offset parameter to retrieve additional records.
+ * The value of this header will always be a string of characters from the urlsafe-base64 alphabet.
+ * The specific contents of the string are an implementation detail of the server,
+ * so clients should treat it as an opaque token.
+ *
+ * @return the offset header
+ */
+ public String weaveOffset() {
+ return this.getNonMissingHeader(X_WEAVE_NEXT_OFFSET);
+ }
+
+ /**
+ * This header gives the last-modified time of the target resource as seen during processing of the request,
+ * and will be included in all success responses (200, 201, 204).
+ * When given in response to a write request, this will be equal to the server’s current time and
+ * to the new last-modified time of any BSOs created or changed by the request.
+ * It is similar to the standard HTTP Last-Modified header,
+ * but the value is a decimal timestamp rather than a HTTP-format date.
+ *
+ * @return the last modified header
+ */
+ @Nullable
+ public String lastModified() {
+ return this.getNonMissingHeader(X_LAST_MODIFIED);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequest.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequest.java
new file mode 100644
index 000000000..3ae672f21
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequest.java
@@ -0,0 +1,145 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.URI;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+
+/**
+ * A request class that handles line-by-line responses. Eventually this will
+ * handle real stream processing; for now, just parse the returned body
+ * line-by-line.
+ *
+ * @author rnewman
+ *
+ */
+public class SyncStorageCollectionRequest extends SyncStorageRequest {
+ private static final String LOG_TAG = "CollectionRequest";
+
+ public SyncStorageCollectionRequest(URI uri) {
+ super(uri);
+ }
+
+ protected volatile boolean aborting = false;
+
+ /**
+ * Instruct the request that it should process no more records,
+ * and decline to notify any more delegate callbacks.
+ */
+ public void abort() {
+ aborting = true;
+ try {
+ this.resource.request.abort();
+ } catch (Exception e) {
+ // Just in case.
+ Logger.warn(LOG_TAG, "Got exception in abort: " + e);
+ }
+ }
+
+ @Override
+ protected BaseResourceDelegate makeResourceDelegate(SyncStorageRequest request) {
+ return new SyncCollectionResourceDelegate((SyncStorageCollectionRequest) request);
+ }
+
+ // TODO: this is awful.
+ public class SyncCollectionResourceDelegate extends
+ SyncStorageResourceDelegate {
+
+ private static final String CONTENT_TYPE_INCREMENTAL = "application/newlines";
+ private static final int FETCH_BUFFER_SIZE = 16 * 1024; // 16K chars.
+
+ SyncCollectionResourceDelegate(SyncStorageCollectionRequest request) {
+ super(request);
+ }
+
+ @Override
+ public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+ super.addHeaders(request, client);
+ request.setHeader("Accept", CONTENT_TYPE_INCREMENTAL);
+ // Caller is responsible for setting full=1.
+ }
+
+ @Override
+ public void handleHttpResponse(HttpResponse response) {
+ if (aborting) {
+ return;
+ }
+
+ if (response.getStatusLine().getStatusCode() != 200) {
+ super.handleHttpResponse(response);
+ return;
+ }
+
+ HttpEntity entity = response.getEntity();
+ Header contentType = entity.getContentType();
+ if (!contentType.getValue().startsWith(CONTENT_TYPE_INCREMENTAL)) {
+ // Not incremental!
+ super.handleHttpResponse(response);
+ return;
+ }
+
+ // TODO: at this point we can access X-Weave-Timestamp, compare
+ // that to our local timestamp, and compute an estimate of clock
+ // skew. We can provide this to the incremental delegate, which
+ // will allow it to seamlessly correct timestamps on the records
+ // it processes. Bug 721887.
+
+ // Line-by-line processing, then invoke success.
+ SyncStorageCollectionRequestDelegate delegate = (SyncStorageCollectionRequestDelegate) this.request.delegate;
+ InputStream content = null;
+ BufferedReader br = null;
+ try {
+ content = entity.getContent();
+ br = new BufferedReader(new InputStreamReader(content), FETCH_BUFFER_SIZE);
+ String line;
+
+ // This relies on connection timeouts at the HTTP layer.
+ while (!aborting &&
+ null != (line = br.readLine())) {
+ try {
+ delegate.handleRequestProgress(line);
+ } catch (Exception ex) {
+ delegate.handleRequestError(new HandleProgressException(ex));
+ BaseResource.consumeEntity(entity);
+ return;
+ }
+ }
+ if (aborting) {
+ // So we don't hit the success case below.
+ return;
+ }
+ } catch (IOException ex) {
+ if (!aborting) {
+ delegate.handleRequestError(ex);
+ }
+ BaseResource.consumeEntity(entity);
+ return;
+ } finally {
+ // Attempt to close the stream and reader.
+ if (br != null) {
+ try {
+ br.close();
+ } catch (IOException e) {
+ // We don't care if this fails.
+ }
+ }
+ }
+ // We're done processing the entity. Don't let fetching the body succeed!
+ BaseResource.consumeEntity(entity);
+ delegate.handleRequestSuccess(new SyncStorageResponse(response));
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequestDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequestDelegate.java
new file mode 100644
index 000000000..ddf52007b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageCollectionRequestDelegate.java
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+public abstract class SyncStorageCollectionRequestDelegate implements
+ SyncStorageRequestIncrementalDelegate, SyncStorageRequestDelegate {
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRecordRequest.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRecordRequest.java
new file mode 100644
index 000000000..c18c4fe15
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRecordRequest.java
@@ -0,0 +1,95 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.sync.CryptoRecord;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+/**
+ * Resource class that implements expected headers and processing for Sync.
+ * Accepts a simplified delegate.
+ *
+ * Includes:
+ * * Basic Auth headers (via Resource)
+ * * Error responses:
+ * * 401
+ * * 503
+ * * Headers:
+ * * Retry-After
+ * * X-Weave-Backoff
+ * * X-Backoff
+ * * X-Weave-Records?
+ * * ...
+ * * Timeouts
+ * * Network errors
+ * * application/newlines
+ * * JSON parsing
+ * * Content-Type and Content-Length validation.
+ */
+public class SyncStorageRecordRequest extends SyncStorageRequest {
+
+ public class SyncStorageRecordResourceDelegate extends SyncStorageResourceDelegate {
+ SyncStorageRecordResourceDelegate(SyncStorageRequest request) {
+ super(request);
+ }
+ }
+
+ public SyncStorageRecordRequest(URI uri) {
+ super(uri);
+ }
+
+ public SyncStorageRecordRequest(String url) throws URISyntaxException {
+ this(new URI(url));
+ }
+
+ @Override
+ protected BaseResourceDelegate makeResourceDelegate(SyncStorageRequest request) {
+ return new SyncStorageRecordResourceDelegate(request);
+ }
+
+ @SuppressWarnings("unchecked")
+ public void post(JSONObject body) {
+ // Let's do this the trivial way for now.
+ // Note that POSTs should be an array, so we wrap here.
+ final JSONArray toPOST = new JSONArray();
+ toPOST.add(body);
+ try {
+ this.resource.post(toPOST);
+ } catch (UnsupportedEncodingException e) {
+ this.delegate.handleRequestError(e);
+ }
+ }
+
+ public void post(JSONArray body) {
+ // Let's do this the trivial way for now.
+ try {
+ this.resource.post(body);
+ } catch (UnsupportedEncodingException e) {
+ this.delegate.handleRequestError(e);
+ }
+ }
+
+ public void put(JSONObject body) {
+ // Let's do this the trivial way for now.
+ try {
+ this.resource.put(body);
+ } catch (UnsupportedEncodingException e) {
+ this.delegate.handleRequestError(e);
+ }
+ }
+
+ public void post(CryptoRecord record) {
+ this.post(record.toJSONObject());
+ }
+
+ public void put(CryptoRecord record) {
+ this.put(record.toJSONObject());
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequest.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequest.java
new file mode 100644
index 000000000..3ede9cded
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequest.java
@@ -0,0 +1,204 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.util.HashMap;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.SyncConstants;
+
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+
+public class SyncStorageRequest implements Resource {
+ public static HashMap<String, String> SERVER_ERROR_MESSAGES;
+ static {
+ HashMap<String, String> errors = new HashMap<String, String>();
+
+ // Sync protocol errors.
+ errors.put("1", "Illegal method/protocol");
+ errors.put("2", "Incorrect/missing CAPTCHA");
+ errors.put("3", "Invalid/missing username");
+ errors.put("4", "Attempt to overwrite data that can't be overwritten (such as creating a user ID that already exists)");
+ errors.put("5", "User ID does not match account in path");
+ errors.put("6", "JSON parse failure");
+ errors.put("7", "Missing password field");
+ errors.put("8", "Invalid Weave Basic Object");
+ errors.put("9", "Requested password not strong enough");
+ errors.put("10", "Invalid/missing password reset code");
+ errors.put("11", "Unsupported function");
+ errors.put("12", "No email address on file");
+ errors.put("13", "Invalid collection");
+ errors.put("14", "User over quota");
+ errors.put("15", "The email does not match the username");
+ errors.put("16", "Client upgrade required");
+ errors.put("255", "An unexpected server error occurred: pool is empty.");
+
+ // Infrastructure-generated errors.
+ errors.put("\"server issue: getVS failed\"", "server issue: getVS failed");
+ errors.put("\"server issue: prefix not set\"", "server issue: prefix not set");
+ errors.put("\"server issue: host header not received from client\"", "server issue: host header not received from client");
+ errors.put("\"server issue: database lookup failed\"", "server issue: database lookup failed");
+ errors.put("\"server issue: database is not healthy\"", "server issue: database is not healthy");
+ errors.put("\"server issue: database not in pool\"", "server issue: database not in pool");
+ errors.put("\"server issue: database marked as down\"", "server issue: database marked as down");
+ SERVER_ERROR_MESSAGES = errors;
+ }
+ public static String getServerErrorMessage(String body) {
+ if (SERVER_ERROR_MESSAGES.containsKey(body)) {
+ return SERVER_ERROR_MESSAGES.get(body);
+ }
+ return body;
+ }
+
+ /**
+ * @param uri
+ * @throws URISyntaxException
+ */
+ public SyncStorageRequest(String uri) throws URISyntaxException {
+ this(new URI(uri));
+ }
+
+ /**
+ * @param uri
+ */
+ public SyncStorageRequest(URI uri) {
+ this.resource = new BaseResource(uri);
+ this.resourceDelegate = this.makeResourceDelegate(this);
+ this.resource.delegate = this.resourceDelegate;
+ }
+
+ @Override
+ public URI getURI() {
+ return this.resource.getURI();
+ }
+
+ @Override
+ public String getURIString() {
+ return this.resource.getURIString();
+ }
+
+ @Override
+ public String getHostname() {
+ return this.resource.getHostname();
+ }
+
+ /**
+ * A ResourceDelegate that mediates between Resource-level notifications and the SyncStorageRequest.
+ */
+ public class SyncStorageResourceDelegate extends BaseResourceDelegate {
+ private static final String LOG_TAG = "SSResourceDelegate";
+ protected SyncStorageRequest request;
+
+ SyncStorageResourceDelegate(SyncStorageRequest request) {
+ super(request);
+ this.request = request;
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return request.delegate.getAuthHeaderProvider();
+ }
+
+ @Override
+ public String getUserAgent() {
+ return SyncConstants.USER_AGENT;
+ }
+
+ @Override
+ public void handleHttpResponse(HttpResponse response) {
+ Logger.debug(LOG_TAG, "SyncStorageResourceDelegate handling response: " + response.getStatusLine() + ".");
+ SyncStorageRequestDelegate d = this.request.delegate;
+ SyncStorageResponse res = new SyncStorageResponse(response);
+ // It is the responsibility of the delegate handlers to completely consume the response.
+ // In context of a Sync storage response, success is either a 200 OK or 202 Accepted.
+ // 202 is returned during uploads of data in a batching mode, indicating that more is expected.
+ if (res.getStatusCode() == 200 || res.getStatusCode() == 202) {
+ d.handleRequestSuccess(res);
+ } else {
+ Logger.warn(LOG_TAG, "HTTP request failed.");
+ try {
+ Logger.warn(LOG_TAG, "HTTP response body: " + res.getErrorMessage());
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Can't fetch HTTP response body.", e);
+ }
+ d.handleRequestFailure(res);
+ }
+ }
+
+ @Override
+ public void handleHttpProtocolException(ClientProtocolException e) {
+ this.request.delegate.handleRequestError(e);
+ }
+
+ @Override
+ public void handleHttpIOException(IOException e) {
+ this.request.delegate.handleRequestError(e);
+ }
+
+ @Override
+ public void handleTransportException(GeneralSecurityException e) {
+ this.request.delegate.handleRequestError(e);
+ }
+
+ @Override
+ public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+ // Clients can use their delegate interface to specify X-If-Unmodified-Since.
+ String ifUnmodifiedSince = this.request.delegate.ifUnmodifiedSince();
+ if (ifUnmodifiedSince != null) {
+ Logger.debug(LOG_TAG, "Making request with X-If-Unmodified-Since = " + ifUnmodifiedSince);
+ request.setHeader("x-if-unmodified-since", ifUnmodifiedSince);
+ }
+ if (request.getMethod().equalsIgnoreCase("DELETE")) {
+ request.addHeader("x-confirm-delete", "1");
+ }
+ }
+ }
+
+ protected BaseResourceDelegate resourceDelegate;
+ public SyncStorageRequestDelegate delegate;
+ protected BaseResource resource;
+
+ public SyncStorageRequest() {
+ super();
+ }
+
+ // Default implementation. Override this.
+ protected BaseResourceDelegate makeResourceDelegate(SyncStorageRequest request) {
+ return new SyncStorageResourceDelegate(request);
+ }
+
+ @Override
+ public void get() {
+ this.resource.get();
+ }
+
+ @Override
+ public void delete() {
+ this.resource.delete();
+ }
+
+ @Override
+ public void post(HttpEntity body) {
+ this.resource.post(body);
+ }
+
+ @Override
+ public void patch(HttpEntity body) {
+ this.resource.patch(body);
+ }
+
+ @Override
+ public void put(HttpEntity body) {
+ this.resource.put(body);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestDelegate.java
new file mode 100644
index 000000000..29f42cc28
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestDelegate.java
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+public interface SyncStorageRequestDelegate {
+ public AuthHeaderProvider getAuthHeaderProvider();
+
+ String ifUnmodifiedSince();
+
+ // TODO: at this point we can access X-Weave-Timestamp, compare
+ // that to our local timestamp, and compute an estimate of clock
+ // skew. Bug 721887.
+
+ /**
+ * Override this to handle a successful SyncStorageRequest.
+ *
+ * SyncStorageResourceDelegate implementers <b>must</b> ensure that the HTTP
+ * responses underlying SyncStorageResponses are fully consumed to ensure that
+ * connections are returned to the pool, for example by calling
+ * <code>BaseResource.consumeEntity(response)</code>.
+ */
+ void handleRequestSuccess(SyncStorageResponse response);
+
+ /**
+ * Override this to handle a failed SyncStorageRequest.
+ *
+ *
+ * SyncStorageResourceDelegate implementers <b>must</b> ensure that the HTTP
+ * responses underlying SyncStorageResponses are fully consumed to ensure that
+ * connections are returned to the pool, for example by calling
+ * <code>BaseResource.consumeEntity(response)</code>.
+ */
+ void handleRequestFailure(SyncStorageResponse response);
+
+ void handleRequestError(Exception ex);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestIncrementalDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestIncrementalDelegate.java
new file mode 100644
index 000000000..aa5d735bf
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageRequestIncrementalDelegate.java
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+public interface SyncStorageRequestIncrementalDelegate {
+ void handleRequestProgress(String progress); // For line-by-line.
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageResponse.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageResponse.java
new file mode 100644
index 000000000..644df314c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/SyncStorageResponse.java
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.io.IOException;
+import java.util.HashMap;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+
+public class SyncStorageResponse extends SyncResponse {
+ private static final String LOG_TAG = "SyncStorageResponse";
+
+ // Responses that are actionable get constant status codes.
+ public static final String RESPONSE_CLIENT_UPGRADE_REQUIRED = "16";
+
+ public static HashMap<String, String> SERVER_ERROR_MESSAGES;
+ static {
+ HashMap<String, String> errors = new HashMap<String, String>();
+
+ // Sync protocol errors.
+ errors.put("1", "Illegal method/protocol");
+ errors.put("2", "Incorrect/missing CAPTCHA");
+ errors.put("3", "Invalid/missing username");
+ errors.put("4", "Attempt to overwrite data that can't be overwritten (such as creating a user ID that already exists)");
+ errors.put("5", "User ID does not match account in path");
+ errors.put("6", "JSON parse failure");
+ errors.put("7", "Missing password field");
+ errors.put("8", "Invalid Weave Basic Object");
+ errors.put("9", "Requested password not strong enough");
+ errors.put("10", "Invalid/missing password reset code");
+ errors.put("11", "Unsupported function");
+ errors.put("12", "No email address on file");
+ errors.put("13", "Invalid collection");
+ errors.put("14", "User over quota");
+ errors.put("15", "The email does not match the username");
+ errors.put(RESPONSE_CLIENT_UPGRADE_REQUIRED, "Client upgrade required");
+ errors.put("255", "An unexpected server error occurred: pool is empty.");
+
+ // Infrastructure-generated errors.
+ errors.put("\"server issue: getVS failed\"", "server issue: getVS failed");
+ errors.put("\"server issue: prefix not set\"", "server issue: prefix not set");
+ errors.put("\"server issue: host header not received from client\"", "server issue: host header not received from client");
+ errors.put("\"server issue: database lookup failed\"", "server issue: database lookup failed");
+ errors.put("\"server issue: database is not healthy\"", "server issue: database is not healthy");
+ errors.put("\"server issue: database not in pool\"", "server issue: database not in pool");
+ errors.put("\"server issue: database marked as down\"", "server issue: database marked as down");
+ SERVER_ERROR_MESSAGES = errors;
+ }
+ public static String getServerErrorMessage(String body) {
+ Logger.debug(LOG_TAG, "Looking up message for body \"" + body + "\"");
+ if (SERVER_ERROR_MESSAGES.containsKey(body)) {
+ return SERVER_ERROR_MESSAGES.get(body);
+ }
+ return body;
+ }
+
+
+ public SyncStorageResponse(HttpResponse res) {
+ super(res);
+ }
+
+ public String getErrorMessage() throws IllegalStateException, IOException {
+ return SyncStorageResponse.getServerErrorMessage(this.body().trim());
+ }
+
+ /**
+ * This header gives the last-modified time of the target resource as seen during processing of
+ * the request, and will be included in all success responses (200, 201, 204).
+ * When given in response to a write request, this will be equal to the server’s current time and
+ * to the new last-modified time of any BSOs created or changed by the request.
+ */
+ public String getLastModified() {
+ if (!response.containsHeader(X_LAST_MODIFIED)) {
+ return null;
+ }
+ return response.getFirstHeader(X_LAST_MODIFIED).getValue();
+ }
+
+ // TODO: Content-Type and Content-Length validation.
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/TLSSocketFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/TLSSocketFactory.java
new file mode 100644
index 000000000..dd68c0515
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/TLSSocketFactory.java
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import java.io.IOException;
+import java.net.Socket;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocket;
+
+import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.background.common.log.Logger;
+
+import ch.boye.httpclientandroidlib.conn.ssl.SSLSocketFactory;
+import ch.boye.httpclientandroidlib.params.HttpParams;
+
+public class TLSSocketFactory extends SSLSocketFactory {
+ private static final String LOG_TAG = "TLSSocketFactory";
+
+ // Guarded by `this`.
+ private static String[] cipherSuites = GlobalConstants.DEFAULT_CIPHER_SUITES;
+
+ public TLSSocketFactory(SSLContext sslContext) {
+ super(sslContext);
+ }
+
+ /**
+ * Attempt to specify the cipher suites to use for a connection. If
+ * setting fails (as it will on Android 2.2, because the wrong names
+ * are in use to specify ciphers), attempt to set the defaults.
+ *
+ * We store the list of cipher suites in `cipherSuites`, which
+ * avoids this fallback handling having to be executed more than once.
+ *
+ * This method is synchronized to ensure correct use of that member.
+ *
+ * See Bug 717691 for more details.
+ *
+ * @param socket
+ * The SSLSocket on which to operate.
+ */
+ public static synchronized void setEnabledCipherSuites(SSLSocket socket) {
+ try {
+ socket.setEnabledCipherSuites(cipherSuites);
+ } catch (IllegalArgumentException e) {
+ cipherSuites = socket.getSupportedCipherSuites();
+ Logger.warn(LOG_TAG, "Setting enabled cipher suites failed: " + e.getMessage());
+ Logger.warn(LOG_TAG, "Using " + cipherSuites.length + " supported suites.");
+ socket.setEnabledCipherSuites(cipherSuites);
+ }
+ }
+
+ @Override
+ public Socket createSocket(HttpParams params) throws IOException {
+ SSLSocket socket = (SSLSocket) super.createSocket(params);
+ socket.setEnabledProtocols(GlobalConstants.DEFAULT_PROTOCOLS);
+ setEnabledCipherSuites(socket);
+ return socket;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBOCollectionRequestDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBOCollectionRequestDelegate.java
new file mode 100644
index 000000000..2e26f041b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBOCollectionRequestDelegate.java
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.KeyBundleProvider;
+
+/**
+ * Subclass this to handle collection fetches.
+ * @author rnewman
+ *
+ */
+public abstract class WBOCollectionRequestDelegate
+extends SyncStorageCollectionRequestDelegate
+implements KeyBundleProvider {
+
+ @Override
+ public abstract KeyBundle keyBundle();
+ public abstract void handleWBO(CryptoRecord record);
+
+ @Override
+ public void handleRequestProgress(String progress) {
+ try {
+ CryptoRecord record = CryptoRecord.fromJSONRecord(progress);
+ record.keyBundle = this.keyBundle();
+ this.handleWBO(record);
+ } catch (Exception e) {
+ this.handleRequestError(e);
+ // TODO: abort?! Allow exception to propagate to fail?
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBORequestDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBORequestDelegate.java
new file mode 100644
index 000000000..8a09e0c7f
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/WBORequestDelegate.java
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.net;
+
+import org.mozilla.gecko.sync.KeyBundleProvider;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+
+public abstract class WBORequestDelegate
+implements SyncStorageRequestDelegate, KeyBundleProvider {
+ @Override
+ public abstract KeyBundle keyBundle();
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarkNeedsReparentingException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarkNeedsReparentingException.java
new file mode 100644
index 000000000..5fe3dc9fa
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarkNeedsReparentingException.java
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class BookmarkNeedsReparentingException extends SyncException {
+
+ private static final long serialVersionUID = -7018336108709392800L;
+
+ public BookmarkNeedsReparentingException(Exception ex) {
+ super(ex);
+ }
+
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarksRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarksRepository.java
new file mode 100644
index 000000000..289fc48ec
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/BookmarksRepository.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+/**
+ * Shared interface for repositories that consume and produce
+ * bookmark records.
+ *
+ * @author rnewman
+ *
+ */
+public interface BookmarksRepository {
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ConstrainedServer11Repository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ConstrainedServer11Repository.java
new file mode 100644
index 000000000..a6dc3f6b8
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ConstrainedServer11Repository.java
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import java.net.URISyntaxException;
+
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.InfoConfiguration;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+
+/**
+ * A kind of Server11Repository that supports explicit setting of total fetch limit, per-batch fetch limit, and a sort order.
+ *
+ * @author rnewman
+ *
+ */
+public class ConstrainedServer11Repository extends Server11Repository {
+
+ private final String sort;
+ private final long batchLimit;
+ private final long totalLimit;
+
+ public ConstrainedServer11Repository(String collection, String storageURL,
+ AuthHeaderProvider authHeaderProvider,
+ InfoCollections infoCollections,
+ InfoConfiguration infoConfiguration,
+ long batchLimit, long totalLimit, String sort)
+ throws URISyntaxException {
+ super(collection, storageURL, authHeaderProvider, infoCollections, infoConfiguration);
+ this.batchLimit = batchLimit;
+ this.totalLimit = totalLimit;
+ this.sort = sort;
+ }
+
+ @Override
+ public String getDefaultSort() {
+ return sort;
+ }
+
+ @Override
+ public long getDefaultBatchLimit() {
+ return batchLimit;
+ }
+
+ @Override
+ public long getDefaultTotalLimit() {
+ return totalLimit;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/FetchFailedException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/FetchFailedException.java
new file mode 100644
index 000000000..8b29a37ba
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/FetchFailedException.java
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class FetchFailedException extends SyncException {
+ private static final long serialVersionUID = -7533105300182522946L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HashSetStoreTracker.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HashSetStoreTracker.java
new file mode 100644
index 000000000..3b6facc31
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HashSetStoreTracker.java
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import java.util.HashSet;
+import java.util.Iterator;
+
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+public class HashSetStoreTracker implements StoreTracker {
+
+ // Guarded by `this`.
+ // Used to store GUIDs that were not locally modified but
+ // have been modified by a call to `store`, and thus
+ // should not be returned by a subsequent fetch.
+ private final HashSet<String> guids;
+
+ public HashSetStoreTracker() {
+ guids = new HashSet<String>();
+ }
+
+ @Override
+ public String toString() {
+ return "#<Tracker: " + guids.size() + " guids tracked.>";
+ }
+
+ @Override
+ public synchronized boolean trackRecordForExclusion(String guid) {
+ return (guid != null) && guids.add(guid);
+ }
+
+ @Override
+ public synchronized boolean isTrackedForExclusion(String guid) {
+ return (guid != null) && guids.contains(guid);
+ }
+
+ @Override
+ public synchronized boolean untrackStoredForExclusion(String guid) {
+ return (guid != null) && guids.remove(guid);
+ }
+
+ @Override
+ public RecordFilter getFilter() {
+ if (guids.size() == 0) {
+ return null;
+ }
+ return new RecordFilter() {
+ @Override
+ public boolean excludeRecord(Record r) {
+ return isTrackedForExclusion(r.guid);
+ }
+ };
+ }
+
+ @Override
+ public Iterator<String> recordsTrackedForExclusion() {
+ return this.guids.iterator();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HistoryRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HistoryRepository.java
new file mode 100644
index 000000000..eddc32102
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/HistoryRepository.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+/**
+ * Shared interface for repositories that consume and produce
+ * history records.
+ *
+ * @author rnewman
+ *
+ */
+public interface HistoryRepository {
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/IdentityRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/IdentityRecordFactory.java
new file mode 100644
index 000000000..acedc66e2
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/IdentityRecordFactory.java
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+public class IdentityRecordFactory extends RecordFactory {
+
+ @Override
+ public Record createRecord(Record record) {
+ return record;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InactiveSessionException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InactiveSessionException.java
new file mode 100644
index 000000000..185f0d724
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InactiveSessionException.java
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class InactiveSessionException extends SyncException {
+
+ private static final long serialVersionUID = 537241160815940991L;
+
+ public InactiveSessionException(Exception ex) {
+ super(ex);
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidBookmarkTypeException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidBookmarkTypeException.java
new file mode 100644
index 000000000..3597276a4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidBookmarkTypeException.java
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class InvalidBookmarkTypeException extends SyncException {
+
+ private static final long serialVersionUID = -6098516814844387449L;
+
+ public InvalidBookmarkTypeException(Exception e) {
+ super(e);
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidRequestException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidRequestException.java
new file mode 100644
index 000000000..3f761e540
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidRequestException.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class InvalidRequestException extends SyncException {
+
+ private static final long serialVersionUID = 4502951350743608243L;
+
+ public InvalidRequestException(Exception ex) {
+ super(ex);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidSessionTransitionException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidSessionTransitionException.java
new file mode 100644
index 000000000..0963892c9
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/InvalidSessionTransitionException.java
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class InvalidSessionTransitionException extends SyncException {
+
+ private static final long serialVersionUID = 4157729859314427281L;
+
+ public InvalidSessionTransitionException(Exception ex) {
+ super(ex);
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/MultipleRecordsForGuidException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/MultipleRecordsForGuidException.java
new file mode 100644
index 000000000..58cca4a49
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/MultipleRecordsForGuidException.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class MultipleRecordsForGuidException extends SyncException {
+
+ private static final long serialVersionUID = 7426987323485324741L;
+
+ public MultipleRecordsForGuidException(Exception ex) {
+ super(ex);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoContentProviderException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoContentProviderException.java
new file mode 100644
index 000000000..85d119a5d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoContentProviderException.java
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+import android.net.Uri;
+
+/**
+ * Raised when a Content Provider cannot be retrieved.
+ *
+ * @author rnewman
+ *
+ */
+public class NoContentProviderException extends SyncException {
+ private static final long serialVersionUID = 1L;
+
+ public final Uri requestedProvider;
+ public NoContentProviderException(Uri requested) {
+ super();
+ this.requestedProvider = requested;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoGuidForIdException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoGuidForIdException.java
new file mode 100644
index 000000000..3681deffd
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoGuidForIdException.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class NoGuidForIdException extends SyncException {
+
+ private static final long serialVersionUID = -675614284405829041L;
+
+ public NoGuidForIdException(Exception ex) {
+ super(ex);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoStoreDelegateException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoStoreDelegateException.java
new file mode 100644
index 000000000..5747039aa
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NoStoreDelegateException.java
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class NoStoreDelegateException extends SyncException {
+ private static final long serialVersionUID = 6631689468978422074L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NullCursorException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NullCursorException.java
new file mode 100644
index 000000000..4d9057992
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/NullCursorException.java
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class NullCursorException extends SyncException {
+
+ private static final long serialVersionUID = 3146506225701104661L;
+
+ public NullCursorException(Exception e) {
+ super(e);
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ParentNotFoundException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ParentNotFoundException.java
new file mode 100644
index 000000000..991fd7426
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ParentNotFoundException.java
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class ParentNotFoundException extends SyncException {
+
+ private static final long serialVersionUID = -2687003621705922982L;
+
+ public ParentNotFoundException(Exception ex) {
+ super(ex);
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ProfileDatabaseException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ProfileDatabaseException.java
new file mode 100644
index 000000000..0f8075133
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ProfileDatabaseException.java
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class ProfileDatabaseException extends SyncException {
+
+ private static final long serialVersionUID = -4916908502042261602L;
+
+ public ProfileDatabaseException(Exception ex) {
+ super(ex);
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFactory.java
new file mode 100644
index 000000000..6a8d81a77
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFactory.java
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+// Take a record retrieved from some middleware, producing
+// some concrete record type for application to some local repository.
+public abstract class RecordFactory {
+ public abstract Record createRecord(Record record);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFilter.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFilter.java
new file mode 100644
index 000000000..733448ded
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RecordFilter.java
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+public interface RecordFilter {
+ public boolean excludeRecord(Record r);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Repository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Repository.java
new file mode 100644
index 000000000..3dd3fd2c4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Repository.java
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCleanDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+
+import android.content.Context;
+
+public abstract class Repository {
+ public abstract void createSession(RepositorySessionCreationDelegate delegate, Context context);
+
+ public void clean(boolean success, RepositorySessionCleanDelegate delegate, Context context) {
+ delegate.onCleaned(this);
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySession.java
new file mode 100644
index 000000000..84fca1379
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySession.java
@@ -0,0 +1,384 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+/**
+ * A <code>RepositorySession</code> is created and used thusly:
+ *
+ *<ul>
+ * <li>Construct, with a reference to its parent {@link Repository}, by calling
+ * {@link Repository#createSession(RepositorySessionCreationDelegate, android.content.Context)}.</li>
+ * <li>Populate with saved information by calling {@link #unbundle(RepositorySessionBundle)}.</li>
+ * <li>Begin a sync by calling {@link #begin(RepositorySessionBeginDelegate)}. <code>begin()</code>
+ * is an appropriate place to initialize expensive resources.</li>
+ * <li>Perform operations such as {@link #fetchSince(long, RepositorySessionFetchRecordsDelegate)} and
+ * {@link #store(Record)}.</li>
+ * <li>Finish by calling {@link #finish(RepositorySessionFinishDelegate)}, retrieving and storing
+ * the current bundle.</li>
+ *</ul>
+ *
+ * If <code>finish()</code> is not called, {@link #abort()} must be called. These calls must
+ * <em>always</em> be paired with <code>begin()</code>.
+ *
+ */
+public abstract class RepositorySession {
+
+ public enum SessionStatus {
+ UNSTARTED,
+ ACTIVE,
+ ABORTED,
+ DONE
+ }
+
+ private static final String LOG_TAG = "RepositorySession";
+
+ protected static void trace(String message) {
+ Logger.trace(LOG_TAG, message);
+ }
+
+ private SessionStatus status = SessionStatus.UNSTARTED;
+ protected Repository repository;
+ protected RepositorySessionStoreDelegate delegate;
+
+ /**
+ * A queue of Runnables which call out into delegates.
+ */
+ protected ExecutorService delegateQueue = Executors.newSingleThreadExecutor();
+
+ /**
+ * A queue of Runnables which effect storing.
+ * This includes actual store work, and also the consequences of storeDone.
+ * This provides strict ordering.
+ */
+ protected ExecutorService storeWorkQueue = Executors.newSingleThreadExecutor();
+
+ // The time that the last sync on this collection completed, in milliseconds since epoch.
+ private long lastSyncTimestamp = 0;
+
+ public long getLastSyncTimestamp() {
+ return lastSyncTimestamp;
+ }
+
+ public static long now() {
+ return System.currentTimeMillis();
+ }
+
+ public RepositorySession(Repository repository) {
+ this.repository = repository;
+ }
+
+ public abstract void guidsSince(long timestamp, RepositorySessionGuidsSinceDelegate delegate);
+ public abstract void fetchSince(long timestamp, RepositorySessionFetchRecordsDelegate delegate);
+ public abstract void fetch(String[] guids, RepositorySessionFetchRecordsDelegate delegate) throws InactiveSessionException;
+ public abstract void fetchAll(RepositorySessionFetchRecordsDelegate delegate);
+
+ /**
+ * Override this if you wish to short-circuit a sync when you know --
+ * e.g., by inspecting the database or info/collections -- that no new
+ * data are available.
+ *
+ * @return true if a sync should proceed.
+ */
+ public boolean dataAvailable() {
+ return true;
+ }
+
+ /**
+ * @return true if we cannot safely sync from this <code>RepositorySession</code>.
+ */
+ public boolean shouldSkip() {
+ return false;
+ }
+
+ /*
+ * Store operations proceed thusly:
+ *
+ * * Set a delegate
+ * * Store an arbitrary number of records. At any time the delegate can be
+ * notified of an error.
+ * * Call storeDone to notify the session that no more items are forthcoming.
+ * * The store delegate will be notified of error or completion.
+ *
+ * This arrangement of calls allows for batching at the session level.
+ *
+ * Store success calls are not guaranteed.
+ */
+ public void setStoreDelegate(RepositorySessionStoreDelegate delegate) {
+ Logger.debug(LOG_TAG, "Setting store delegate to " + delegate);
+ this.delegate = delegate;
+ }
+ public abstract void store(Record record) throws NoStoreDelegateException;
+
+ public void storeDone() {
+ // Our default behavior will be to assume that the Runnable is
+ // executed as soon as all the stores synchronously finish, so
+ // our end timestamp can just be… now.
+ storeDone(now());
+ }
+
+ public void storeDone(final long end) {
+ Logger.debug(LOG_TAG, "Scheduling onStoreCompleted for after storing is done: " + end);
+ Runnable command = new Runnable() {
+ @Override
+ public void run() {
+ delegate.onStoreCompleted(end);
+ }
+ };
+ storeWorkQueue.execute(command);
+ }
+
+ public abstract void wipe(RepositorySessionWipeDelegate delegate);
+
+ /**
+ * Synchronously perform the shared work of beginning. Throws on failure.
+ * @throws InvalidSessionTransitionException
+ *
+ */
+ protected void sharedBegin() throws InvalidSessionTransitionException {
+ Logger.debug(LOG_TAG, "Shared begin.");
+ if (delegateQueue.isShutdown()) {
+ throw new InvalidSessionTransitionException(null);
+ }
+ if (storeWorkQueue.isShutdown()) {
+ throw new InvalidSessionTransitionException(null);
+ }
+ this.transitionFrom(SessionStatus.UNSTARTED, SessionStatus.ACTIVE);
+ }
+
+ /**
+ * Start the session. This is an appropriate place to initialize
+ * data access components such as database handles.
+ *
+ * @param delegate
+ * @throws InvalidSessionTransitionException
+ */
+ public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
+ sharedBegin();
+ delegate.deferredBeginDelegate(delegateQueue).onBeginSucceeded(this);
+ }
+
+ public void unbundle(RepositorySessionBundle bundle) {
+ this.lastSyncTimestamp = bundle == null ? 0 : bundle.getTimestamp();
+ }
+
+ /**
+ * Override this in your subclasses to return values to save between sessions.
+ * Note that RepositorySession automatically bumps the timestamp to the time
+ * the last sync began. If unbundled but not begun, this will be the same as the
+ * value in the input bundle.
+ *
+ * The Synchronizer most likely wants to bump the bundle timestamp to be a value
+ * return from a fetch call.
+ */
+ protected RepositorySessionBundle getBundle() {
+ // Why don't we just persist the old bundle?
+ long timestamp = getLastSyncTimestamp();
+ RepositorySessionBundle bundle = new RepositorySessionBundle(timestamp);
+ Logger.debug(LOG_TAG, "Setting bundle timestamp to " + timestamp + ".");
+
+ return bundle;
+ }
+
+ /**
+ * Just like finish(), but doesn't do any work that should only be performed
+ * at the end of a successful sync, and can be called any time.
+ */
+ public void abort(RepositorySessionFinishDelegate delegate) {
+ this.abort();
+ delegate.deferredFinishDelegate(delegateQueue).onFinishSucceeded(this, this.getBundle());
+ }
+
+ /**
+ * Abnormally terminate the repository session, freeing or closing
+ * any resources that were opened during the lifetime of the session.
+ */
+ public void abort() {
+ // TODO: do something here.
+ this.setStatus(SessionStatus.ABORTED);
+ try {
+ storeWorkQueue.shutdownNow();
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Caught exception shutting down store work queue.", e);
+ }
+ try {
+ delegateQueue.shutdown();
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Caught exception shutting down delegate queue.", e);
+ }
+ }
+
+ /**
+ * End the repository session, freeing or closing any resources
+ * that were opened during the lifetime of the session.
+ *
+ * @param delegate notified of success or failure.
+ * @throws InactiveSessionException
+ */
+ public void finish(final RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
+ try {
+ this.transitionFrom(SessionStatus.ACTIVE, SessionStatus.DONE);
+ delegate.deferredFinishDelegate(delegateQueue).onFinishSucceeded(this, this.getBundle());
+ } catch (InvalidSessionTransitionException e) {
+ Logger.error(LOG_TAG, "Tried to finish() an unstarted or already finished session");
+ throw new InactiveSessionException(e);
+ }
+
+ Logger.trace(LOG_TAG, "Shutting down work queues.");
+ storeWorkQueue.shutdown();
+ delegateQueue.shutdown();
+ }
+
+ /**
+ * Run the provided command if we're active and our delegate queue
+ * is not shut down.
+ */
+ protected synchronized void executeDelegateCommand(Runnable command)
+ throws InactiveSessionException {
+ if (!isActive() || delegateQueue.isShutdown()) {
+ throw new InactiveSessionException(null);
+ }
+ delegateQueue.execute(command);
+ }
+
+ public synchronized void ensureActive() throws InactiveSessionException {
+ if (!isActive()) {
+ throw new InactiveSessionException(null);
+ }
+ }
+
+ public synchronized boolean isActive() {
+ return status == SessionStatus.ACTIVE;
+ }
+
+ public synchronized SessionStatus getStatus() {
+ return status;
+ }
+
+ public synchronized void setStatus(SessionStatus status) {
+ this.status = status;
+ }
+
+ public synchronized void transitionFrom(SessionStatus from, SessionStatus to) throws InvalidSessionTransitionException {
+ if (from == null || this.status == from) {
+ Logger.trace(LOG_TAG, "Successfully transitioning from " + this.status + " to " + to);
+
+ this.status = to;
+ return;
+ }
+ Logger.warn(LOG_TAG, "Wanted to transition from " + from + " but in state " + this.status);
+ throw new InvalidSessionTransitionException(null);
+ }
+
+ /**
+ * Produce a record that is some combination of the remote and local records
+ * provided.
+ *
+ * The returned record must be produced without mutating either remoteRecord
+ * or localRecord. It is acceptable to return either remoteRecord or localRecord
+ * if no modifications are to be propagated.
+ *
+ * The returned record *should* have the local androidID and the remote GUID,
+ * and some optional merge of data from the two records.
+ *
+ * This method can be called with records that are identical, or differ in
+ * any regard.
+ *
+ * This method will not be called if:
+ *
+ * * either record is marked as deleted, or
+ * * there is no local mapping for a new remote record.
+ *
+ * Otherwise, it will be called precisely once.
+ *
+ * Side-effects (e.g., for transactional storage) can be hooked in here.
+ *
+ * @param remoteRecord
+ * The record retrieved from upstream, already adjusted for clock skew.
+ * @param localRecord
+ * The record retrieved from local storage.
+ * @param lastRemoteRetrieval
+ * The timestamp of the last retrieved set of remote records, adjusted for
+ * clock skew.
+ * @param lastLocalRetrieval
+ * The timestamp of the last retrieved set of local records.
+ * @return
+ * A Record instance to apply, or null to apply nothing.
+ */
+ protected Record reconcileRecords(final Record remoteRecord,
+ final Record localRecord,
+ final long lastRemoteRetrieval,
+ final long lastLocalRetrieval) {
+ Logger.debug(LOG_TAG, "Reconciling remote " + remoteRecord.guid + " against local " + localRecord.guid);
+
+ if (localRecord.equalPayloads(remoteRecord)) {
+ if (remoteRecord.lastModified > localRecord.lastModified) {
+ Logger.debug(LOG_TAG, "Records are equal. No record application needed.");
+ return null;
+ }
+
+ // Local wins.
+ return null;
+ }
+
+ // TODO: Decide what to do based on:
+ // * Which of the two records is modified;
+ // * Whether they are equal or congruent;
+ // * The modified times of each record (interpreted through the lens of clock skew);
+ // * ...
+ boolean localIsMoreRecent = localRecord.lastModified > remoteRecord.lastModified;
+ Logger.debug(LOG_TAG, "Local record is more recent? " + localIsMoreRecent);
+ Record donor = localIsMoreRecent ? localRecord : remoteRecord;
+
+ // Modify the local record to match the remote record's GUID and values.
+ // Preserve the local Android ID, and merge data where possible.
+ // It sure would be nice if copyWithIDs didn't give a shit about androidID, mm?
+ Record out = donor.copyWithIDs(remoteRecord.guid, localRecord.androidID);
+
+ // We don't want to upload the record if the remote record was
+ // applied without changes.
+ // This logic will become more complicated as reconciling becomes smarter.
+ if (!localIsMoreRecent) {
+ trackGUID(out.guid);
+ }
+ return out;
+ }
+
+ /**
+ * Depending on the RepositorySession implementation, track
+ * that a record — most likely a brand-new record that has been
+ * applied unmodified — should be tracked so as to not be uploaded
+ * redundantly.
+ *
+ * The default implementations do nothing.
+ */
+ protected void trackGUID(String guid) {
+ }
+
+ protected synchronized void untrackGUIDs(Collection<String> guids) {
+ }
+
+ protected void untrackGUID(String guid) {
+ }
+
+ // Ah, Java. You wretched creature.
+ public Iterator<String> getTrackedRecordIDs() {
+ return new ArrayList<String>().iterator();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySessionBundle.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySessionBundle.java
new file mode 100644
index 000000000..7908ec797
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/RepositorySessionBundle.java
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+
+import java.io.IOException;
+
+public class RepositorySessionBundle {
+ public static final String LOG_TAG = RepositorySessionBundle.class.getSimpleName();
+
+ protected static final String JSON_KEY_TIMESTAMP = "timestamp";
+
+ protected final ExtendedJSONObject object;
+
+ public RepositorySessionBundle(String jsonString) throws IOException, NonObjectJSONException {
+
+ object = new ExtendedJSONObject(jsonString);
+ }
+
+ public RepositorySessionBundle(long lastSyncTimestamp) {
+ object = new ExtendedJSONObject();
+ this.setTimestamp(lastSyncTimestamp);
+ }
+
+ public long getTimestamp() {
+ if (object.containsKey(JSON_KEY_TIMESTAMP)) {
+ return object.getLong(JSON_KEY_TIMESTAMP);
+ }
+
+ return -1;
+ }
+
+ public void setTimestamp(long timestamp) {
+ Logger.debug(LOG_TAG, "Setting timestamp to " + timestamp + ".");
+ object.put(JSON_KEY_TIMESTAMP, timestamp);
+ }
+
+ public void bumpTimestamp(long timestamp) {
+ long existing = this.getTimestamp();
+ if (timestamp > existing) {
+ this.setTimestamp(timestamp);
+ } else {
+ Logger.debug(LOG_TAG, "Timestamp " + timestamp + " not greater than " + existing + "; not bumping.");
+ }
+ }
+
+ public String toJSONString() {
+ return object.toJSONString();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11Repository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11Repository.java
new file mode 100644
index 000000000..4404fda25
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11Repository.java
@@ -0,0 +1,144 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.InfoConfiguration;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+/**
+ * A Server11Repository implements fetching and storing against the Sync 1.1 API.
+ * It doesn't do crypto: that's the job of the middleware.
+ *
+ * @author rnewman
+ */
+public class Server11Repository extends Repository {
+ protected String collection;
+ protected URI collectionURI;
+ protected final AuthHeaderProvider authHeaderProvider;
+ protected final InfoCollections infoCollections;
+
+ private final InfoConfiguration infoConfiguration;
+
+ /**
+ * Construct a new repository that fetches and stores against the Sync 1.1. API.
+ *
+ * @param collection name.
+ * @param storageURL full URL to storage endpoint.
+ * @param authHeaderProvider to use in requests; may be null.
+ * @param infoCollections instance; must not be null.
+ * @throws URISyntaxException
+ */
+ public Server11Repository(@NonNull String collection, @NonNull String storageURL, AuthHeaderProvider authHeaderProvider, @NonNull InfoCollections infoCollections, @NonNull InfoConfiguration infoConfiguration) throws URISyntaxException {
+ if (collection == null) {
+ throw new IllegalArgumentException("collection must not be null");
+ }
+ if (storageURL == null) {
+ throw new IllegalArgumentException("storageURL must not be null");
+ }
+ if (infoCollections == null) {
+ throw new IllegalArgumentException("infoCollections must not be null");
+ }
+ this.collection = collection;
+ this.collectionURI = new URI(storageURL + (storageURL.endsWith("/") ? collection : "/" + collection));
+ this.authHeaderProvider = authHeaderProvider;
+ this.infoCollections = infoCollections;
+ this.infoConfiguration = infoConfiguration;
+ }
+
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ delegate.onSessionCreated(new Server11RepositorySession(this));
+ }
+
+ public URI collectionURI() {
+ return this.collectionURI;
+ }
+
+ public URI collectionURI(boolean full, long newer, long limit, String sort, String ids, String offset) throws URISyntaxException {
+ ArrayList<String> params = new ArrayList<String>();
+ if (full) {
+ params.add("full=1");
+ }
+ if (newer >= 0) {
+ // Translate local millisecond timestamps into server decimal seconds.
+ String newerString = Utils.millisecondsToDecimalSecondsString(newer);
+ params.add("newer=" + newerString);
+ }
+ if (limit > 0) {
+ params.add("limit=" + limit);
+ }
+ if (sort != null) {
+ params.add("sort=" + sort); // We trust these values.
+ }
+ if (ids != null) {
+ params.add("ids=" + ids); // We trust these values.
+ }
+ if (offset != null) {
+ // Offset comes straight out of HTTP headers and it is the responsibility of the caller to URI-escape it.
+ params.add("offset=" + offset);
+ }
+ if (params.size() == 0) {
+ return this.collectionURI;
+ }
+
+ StringBuilder out = new StringBuilder();
+ char indicator = '?';
+ for (String param : params) {
+ out.append(indicator);
+ indicator = '&';
+ out.append(param);
+ }
+ String uri = this.collectionURI + out.toString();
+ return new URI(uri);
+ }
+
+ public URI wboURI(String id) throws URISyntaxException {
+ return new URI(this.collectionURI + "/" + id);
+ }
+
+ // Override these.
+ @SuppressWarnings("static-method")
+ public long getDefaultBatchLimit() {
+ return -1;
+ }
+
+ @SuppressWarnings("static-method")
+ public String getDefaultSort() {
+ return null;
+ }
+
+ public long getDefaultTotalLimit() {
+ return -1;
+ }
+
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return authHeaderProvider;
+ }
+
+ public boolean updateNeeded(long lastSyncTimestamp) {
+ return infoCollections.updateNeeded(collection, lastSyncTimestamp);
+ }
+
+ @Nullable
+ public Long getCollectionLastModified() {
+ return infoCollections.getTimestamp(collection);
+ }
+
+ public InfoConfiguration getInfoConfiguration() {
+ return infoConfiguration;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11RepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11RepositorySession.java
new file mode 100644
index 000000000..20c735a6b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11RepositorySession.java
@@ -0,0 +1,104 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+import org.mozilla.gecko.sync.repositories.downloaders.BatchingDownloader;
+import org.mozilla.gecko.sync.repositories.uploaders.BatchingUploader;
+
+public class Server11RepositorySession extends RepositorySession {
+ public static final String LOG_TAG = "Server11Session";
+
+ Server11Repository serverRepository;
+ private BatchingUploader uploader;
+ private final BatchingDownloader downloader;
+
+ public Server11RepositorySession(Repository repository) {
+ super(repository);
+ serverRepository = (Server11Repository) repository;
+ this.downloader = new BatchingDownloader(serverRepository, this);
+ }
+
+ public Server11Repository getServerRepository() {
+ return serverRepository;
+ }
+
+ @Override
+ public void setStoreDelegate(RepositorySessionStoreDelegate delegate) {
+ this.delegate = delegate;
+
+ // Now that we have the delegate, we can initialize our uploader.
+ this.uploader = new BatchingUploader(this, storeWorkQueue, delegate);
+ }
+
+ @Override
+ public void guidsSince(long timestamp,
+ RepositorySessionGuidsSinceDelegate delegate) {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public void fetchSince(long timestamp,
+ RepositorySessionFetchRecordsDelegate delegate) {
+ this.downloader.fetchSince(timestamp, delegate);
+ }
+
+ @Override
+ public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) {
+ this.fetchSince(-1, delegate);
+ }
+
+ @Override
+ public void fetch(String[] guids,
+ RepositorySessionFetchRecordsDelegate delegate) {
+ this.downloader.fetch(guids, delegate);
+ }
+
+ @Override
+ public void wipe(RepositorySessionWipeDelegate delegate) {
+ if (!isActive()) {
+ delegate.onWipeFailed(new InactiveSessionException(null));
+ return;
+ }
+ // TODO: implement wipe.
+ }
+
+ @Override
+ public void store(Record record) throws NoStoreDelegateException {
+ if (delegate == null) {
+ throw new NoStoreDelegateException();
+ }
+
+ // If delegate was set, this shouldn't happen.
+ if (uploader == null) {
+ throw new IllegalStateException("Uploader haven't been initialized");
+ }
+
+ uploader.process(record);
+ }
+
+ @Override
+ public void storeDone() {
+ Logger.debug(LOG_TAG, "storeDone().");
+
+ // If delegate was set, this shouldn't happen.
+ if (uploader == null) {
+ throw new IllegalStateException("Uploader haven't been initialized");
+ }
+
+ uploader.noMoreRecordsToUpload();
+ }
+
+ @Override
+ public boolean dataAvailable() {
+ return serverRepository.updateNeeded(getLastSyncTimestamp());
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreFailedException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreFailedException.java
new file mode 100644
index 000000000..fcb09e32e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreFailedException.java
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import org.mozilla.gecko.sync.SyncException;
+
+public class StoreFailedException extends SyncException {
+ private static final long serialVersionUID = 6080340122855859752L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTracker.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTracker.java
new file mode 100644
index 000000000..b6a3071a9
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTracker.java
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import java.util.Iterator;
+
+/**
+ * Our hacky version of transactional semantics. The goal is to prevent
+ * the following situation:
+ *
+ * * AAA is not modified locally.
+ * * A modified AAA is downloaded during the storing phase. Its local
+ * timestamp is advanced.
+ * * The direction of syncing changes, and AAA is now uploaded to the server.
+ *
+ * The following situation should still be supported:
+ *
+ * * AAA is not modified locally.
+ * * A modified AAA is downloaded and merged with the local AAA.
+ * * The merged AAA is uploaded to the server.
+ *
+ * As should:
+ *
+ * * AAA is modified locally.
+ * * A modified AAA is downloaded, and discarded or merged.
+ * * The current version of AAA is uploaded to the server.
+ *
+ * We achieve this by tracking GUIDs during the storing phase. If we
+ * apply a record such that the local copy is substantially the same
+ * as the record we just downloaded, we add it to a list of records
+ * to avoid uploading. The definition of "substantially the same"
+ * depends on the particular repository. The only consideration is "do we
+ * want to upload this record in this sync?".
+ *
+ * Note that items are removed from this list when a fetch that
+ * considers them for upload completes successfully. The entire list
+ * is discarded when the session is completed.
+ *
+ * This interface exposes methods to:
+ *
+ * * During a store, recording that a record has been stored, and should
+ * thus not be returned in subsequent fetches;
+ * * During a fetch, checking whether a record should be returned.
+ *
+ * In the future this might also grow self-persistence.
+ *
+ * See also RepositorySession.trackRecord.
+ *
+ * @author rnewman
+ *
+ */
+public interface StoreTracker {
+
+ /**
+ * @param guid
+ * The GUID of the item to track.
+ * @return
+ * Whether the GUID was a newly tracked value.
+ */
+ public boolean trackRecordForExclusion(String guid);
+
+ /**
+ * @param guid
+ * The GUID of the item to check.
+ * @return
+ * true if the item is already tracked.
+ */
+ public boolean isTrackedForExclusion(String guid);
+
+ /**
+ *
+ * @param guid
+ * @return true if the specified GUID was removed from the tracked set.
+ */
+ public boolean untrackStoredForExclusion(String guid);
+
+ public RecordFilter getFilter();
+
+ public Iterator<String> recordsTrackedForExclusion();
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTrackingRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTrackingRepositorySession.java
new file mode 100644
index 000000000..1a5c1e96a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/StoreTrackingRepositorySession.java
@@ -0,0 +1,102 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories;
+
+import java.util.Collection;
+import java.util.Iterator;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+public abstract class StoreTrackingRepositorySession extends RepositorySession {
+ private static final String LOG_TAG = "StoreTrackSession";
+ protected StoreTracker storeTracker;
+
+ protected static StoreTracker createStoreTracker() {
+ return new HashSetStoreTracker();
+ }
+
+ public StoreTrackingRepositorySession(Repository repository) {
+ super(repository);
+ }
+
+ @Override
+ public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
+ RepositorySessionBeginDelegate deferredDelegate = delegate.deferredBeginDelegate(delegateQueue);
+ try {
+ super.sharedBegin();
+ } catch (InvalidSessionTransitionException e) {
+ deferredDelegate.onBeginFailed(e);
+ return;
+ }
+ // Or do this in your own subclass.
+ storeTracker = createStoreTracker();
+ deferredDelegate.onBeginSucceeded(this);
+ }
+
+ @Override
+ protected synchronized void trackGUID(String guid) {
+ if (this.storeTracker == null) {
+ throw new IllegalStateException("Store tracker not yet initialized!");
+ }
+ this.storeTracker.trackRecordForExclusion(guid);
+ }
+
+ @Override
+ protected synchronized void untrackGUID(String guid) {
+ if (this.storeTracker == null) {
+ throw new IllegalStateException("Store tracker not yet initialized!");
+ }
+ this.storeTracker.untrackStoredForExclusion(guid);
+ }
+
+ @Override
+ protected synchronized void untrackGUIDs(Collection<String> guids) {
+ if (this.storeTracker == null) {
+ throw new IllegalStateException("Store tracker not yet initialized!");
+ }
+ if (guids == null) {
+ return;
+ }
+ for (String guid : guids) {
+ this.storeTracker.untrackStoredForExclusion(guid);
+ }
+ }
+
+ protected void trackRecord(Record record) {
+
+ Logger.debug(LOG_TAG, "Tracking record " + record.guid +
+ " (" + record.lastModified + ") to avoid re-upload.");
+ // Future: we care about the timestamp…
+ trackGUID(record.guid);
+ }
+
+ protected void untrackRecord(Record record) {
+ Logger.debug(LOG_TAG, "Un-tracking record " + record.guid + ".");
+ untrackGUID(record.guid);
+ }
+
+ @Override
+ public Iterator<String> getTrackedRecordIDs() {
+ if (this.storeTracker == null) {
+ throw new IllegalStateException("Store tracker not yet initialized!");
+ }
+ return this.storeTracker.recordsTrackedForExclusion();
+ }
+
+ @Override
+ public void abort(RepositorySessionFinishDelegate delegate) {
+ this.storeTracker = null;
+ super.abort(delegate);
+ }
+
+ @Override
+ public void finish(RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
+ super.finish(delegate);
+ this.storeTracker = null;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksDataAccessor.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksDataAccessor.java
new file mode 100644
index 000000000..fd3c35da0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksDataAccessor.java
@@ -0,0 +1,326 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+
+public class AndroidBrowserBookmarksDataAccessor extends AndroidBrowserRepositoryDataAccessor {
+
+ private static final String LOG_TAG = "BookmarksDataAccessor";
+
+ /*
+ * Fragments of SQL to make our lives easier.
+ */
+ private static final String BOOKMARK_IS_FOLDER = BrowserContract.Bookmarks.TYPE + " = " +
+ BrowserContract.Bookmarks.TYPE_FOLDER;
+
+ // SQL fragment to retrieve GUIDs whose ID mappings should be tracked by this session.
+ // Exclude folders we don't want to sync.
+ private static final String GUID_SHOULD_TRACK = BrowserContract.SyncColumns.GUID + " NOT IN ('" +
+ BrowserContract.Bookmarks.TAGS_FOLDER_GUID + "', '" +
+ BrowserContract.Bookmarks.PLACES_FOLDER_GUID + "', '" +
+ BrowserContract.Bookmarks.PINNED_FOLDER_GUID + "')";
+
+ private static final String EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE;
+ static {
+ if (AndroidBrowserBookmarksRepositorySession.SPECIAL_GUIDS.length > 0) {
+ StringBuilder b = new StringBuilder(BrowserContract.SyncColumns.GUID + " NOT IN (");
+
+ int remaining = AndroidBrowserBookmarksRepositorySession.SPECIAL_GUIDS.length - 1;
+ for (String specialGuid : AndroidBrowserBookmarksRepositorySession.SPECIAL_GUIDS) {
+ b.append('"');
+ b.append(specialGuid);
+ b.append('"');
+ if (remaining-- > 0) {
+ b.append(", ");
+ }
+ }
+ b.append(')');
+ EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE = b.toString();
+ } else {
+ EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE = null; // null is a valid WHERE clause.
+ }
+ }
+
+ public static final String TYPE_FOLDER = "folder";
+ public static final String TYPE_BOOKMARK = "bookmark";
+
+ private final RepoUtils.QueryHelper queryHelper;
+
+ public AndroidBrowserBookmarksDataAccessor(Context context) {
+ super(context);
+ this.queryHelper = new RepoUtils.QueryHelper(context, getUri(), LOG_TAG);
+ }
+
+ @Override
+ protected Uri getUri() {
+ return BrowserContractHelpers.BOOKMARKS_CONTENT_URI;
+ }
+
+ protected static Uri getPositionsUri() {
+ return BrowserContractHelpers.BOOKMARKS_POSITIONS_CONTENT_URI;
+ }
+
+ @Override
+ public void wipe() {
+ Uri uri = getUri();
+ Logger.info(LOG_TAG, "wiping (except for special guids): " + uri);
+ context.getContentResolver().delete(uri, EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE, null);
+ }
+
+ private final String[] GUID_AND_ID = new String[] { BrowserContract.Bookmarks.GUID,
+ BrowserContract.Bookmarks._ID };
+
+ protected Cursor getGuidsIDsForFolders() throws NullCursorException {
+ // Exclude items that we don't want to sync (pinned items, reading list,
+ // tags, the places root), in case they've ended up in the DB.
+ String where = BOOKMARK_IS_FOLDER + " AND " + GUID_SHOULD_TRACK;
+ return queryHelper.safeQuery(".getGuidsIDsForFolders", GUID_AND_ID, where, null, null);
+ }
+
+ /**
+ * Issue a request to the Content Provider to update the positions of the
+ * records named by the provided GUIDs to the index of their GUID in the
+ * provided array.
+ *
+ * @param childArray
+ * A sequence of GUID strings.
+ */
+ public int updatePositions(ArrayList<String> childArray) {
+ final int size = childArray.size();
+ if (size == 0) {
+ return 0;
+ }
+
+ Logger.debug(LOG_TAG, "Updating positions for " + size + " items.");
+ String[] args = childArray.toArray(new String[size]);
+ return context.getContentResolver().update(getPositionsUri(), new ContentValues(), null, args);
+ }
+
+ public int bumpModifiedByGUID(Collection<String> ids, long modified) {
+ final int size = ids.size();
+ if (size == 0) {
+ return 0;
+ }
+
+ Logger.debug(LOG_TAG, "Bumping modified for " + size + " items to " + modified);
+ String where = RepoUtils.computeSQLInClause(size, BrowserContract.Bookmarks.GUID);
+ String[] selectionArgs = ids.toArray(new String[size]);
+ ContentValues values = new ContentValues();
+ values.put(BrowserContract.Bookmarks.DATE_MODIFIED, modified);
+
+ return context.getContentResolver().update(getUri(), values, where, selectionArgs);
+ }
+
+ /**
+ * Bump the modified time of a record by ID.
+ */
+ public int bumpModified(long id, long modified) {
+ Logger.debug(LOG_TAG, "Bumping modified for " + id + " to " + modified);
+ String where = BrowserContract.Bookmarks._ID + " = ?";
+ String[] selectionArgs = new String[] { String.valueOf(id) };
+ ContentValues values = new ContentValues();
+ values.put(BrowserContract.Bookmarks.DATE_MODIFIED, modified);
+
+ return context.getContentResolver().update(getUri(), values, where, selectionArgs);
+ }
+
+ protected void updateParentAndPosition(String guid, long newParentId, long position) {
+ ContentValues cv = new ContentValues();
+ cv.put(BrowserContract.Bookmarks.PARENT, newParentId);
+ if (position >= 0) {
+ cv.put(BrowserContract.Bookmarks.POSITION, position);
+ }
+ updateByGuid(guid, cv);
+ }
+
+ protected Map<String, Long> idsForGUIDs(String[] guids) throws NullCursorException {
+ final String where = RepoUtils.computeSQLInClause(guids.length, BrowserContract.Bookmarks.GUID);
+ Cursor c = queryHelper.safeQuery(".idsForGUIDs", GUID_AND_ID, where, guids, null);
+ try {
+ HashMap<String, Long> out = new HashMap<String, Long>();
+ if (!c.moveToFirst()) {
+ return out;
+ }
+ final int guidIndex = c.getColumnIndexOrThrow(BrowserContract.Bookmarks.GUID);
+ final int idIndex = c.getColumnIndexOrThrow(BrowserContract.Bookmarks._ID);
+ while (!c.isAfterLast()) {
+ out.put(c.getString(guidIndex), c.getLong(idIndex));
+ c.moveToNext();
+ }
+ return out;
+ } finally {
+ c.close();
+ }
+ }
+
+ /**
+ * Move the children of each source folder to the destination folder.
+ * Bump the modified time of each child.
+ * The caller should bump the modified time of the destination if desired.
+ *
+ * @param fromIDs the Android IDs of the source folders.
+ * @param to the Android ID of the destination folder.
+ * @return the number of updated rows.
+ */
+ protected int moveChildren(String[] fromIDs, long to) {
+ long now = System.currentTimeMillis();
+ long pos = -1;
+
+ ContentValues cv = new ContentValues();
+ cv.put(BrowserContract.Bookmarks.PARENT, to);
+ cv.put(BrowserContract.Bookmarks.DATE_MODIFIED, now);
+ cv.put(BrowserContract.Bookmarks.POSITION, pos);
+
+ final String where = RepoUtils.computeSQLInClause(fromIDs.length, BrowserContract.Bookmarks.PARENT);
+ return context.getContentResolver().update(getUri(), cv, where, fromIDs);
+ }
+
+ /*
+ * Verify that all special GUIDs are present and that they aren't marked as deleted.
+ * Insert them if they aren't there.
+ */
+ public void checkAndBuildSpecialGuids() throws NullCursorException {
+ final String[] specialGUIDs = AndroidBrowserBookmarksRepositorySession.SPECIAL_GUIDS;
+ Cursor cur = fetch(specialGUIDs);
+ long placesRoot = 0;
+
+ // Map from GUID to whether deleted. Non-presence implies just that.
+ HashMap<String, Boolean> statuses = new HashMap<String, Boolean>(specialGUIDs.length);
+ try {
+ if (cur.moveToFirst()) {
+ while (!cur.isAfterLast()) {
+ String guid = RepoUtils.getStringFromCursor(cur, BrowserContract.SyncColumns.GUID);
+ if ("places".equals(guid)) {
+ placesRoot = RepoUtils.getLongFromCursor(cur, BrowserContract.CommonColumns._ID);
+ }
+ // Make sure none of these folders are marked as deleted.
+ boolean deleted = RepoUtils.getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) == 1;
+ statuses.put(guid, deleted);
+ cur.moveToNext();
+ }
+ }
+ } finally {
+ cur.close();
+ }
+
+ // Insert or undelete them if missing.
+ for (String guid : specialGUIDs) {
+ if (statuses.containsKey(guid)) {
+ if (statuses.get(guid)) {
+ // Undelete.
+ Logger.info(LOG_TAG, "Undeleting special GUID " + guid);
+ ContentValues cv = new ContentValues();
+ cv.put(BrowserContract.SyncColumns.IS_DELETED, 0);
+ updateByGuid(guid, cv);
+ }
+ } else {
+ // Insert.
+ if (guid.equals("places")) {
+ // This is awkward.
+ Logger.info(LOG_TAG, "No places root. Inserting one.");
+ placesRoot = insertSpecialFolder("places", 0);
+ } else if (guid.equals("mobile")) {
+ Logger.info(LOG_TAG, "No mobile folder. Inserting one under the places root.");
+ insertSpecialFolder("mobile", placesRoot);
+ } else {
+ // unfiled, menu, toolbar.
+ Logger.info(LOG_TAG, "No " + guid + " root. Inserting one under places (" + placesRoot + ").");
+ insertSpecialFolder(guid, placesRoot);
+ }
+ }
+ }
+ }
+
+ private long insertSpecialFolder(String guid, long parentId) {
+ BookmarkRecord record = new BookmarkRecord(guid);
+ record.title = AndroidBrowserBookmarksRepositorySession.SPECIAL_GUIDS_MAP.get(guid);
+ record.type = "folder";
+ record.androidParentID = parentId;
+ return ContentUris.parseId(insert(record));
+ }
+
+ @Override
+ protected ContentValues getContentValues(Record record) {
+ BookmarkRecord rec = (BookmarkRecord) record;
+
+ if (rec.deleted) {
+ ContentValues cv = new ContentValues();
+ cv.put(BrowserContract.SyncColumns.GUID, rec.guid);
+ cv.put(BrowserContract.Bookmarks.IS_DELETED, 1);
+ return cv;
+ }
+
+ final int recordType = BrowserContractHelpers.typeCodeForString(rec.type);
+ if (recordType == -1) {
+ throw new IllegalStateException("Unexpected record type " + rec.type);
+ }
+
+ ContentValues cv = new ContentValues();
+ cv.put(BrowserContract.SyncColumns.GUID, rec.guid);
+ cv.put(BrowserContract.Bookmarks.TYPE, recordType);
+ cv.put(BrowserContract.Bookmarks.TITLE, rec.title);
+ cv.put(BrowserContract.Bookmarks.URL, rec.bookmarkURI);
+ cv.put(BrowserContract.Bookmarks.DESCRIPTION, rec.description);
+ if (rec.tags == null) {
+ rec.tags = new JSONArray();
+ }
+ cv.put(BrowserContract.Bookmarks.TAGS, rec.tags.toJSONString());
+ cv.put(BrowserContract.Bookmarks.KEYWORD, rec.keyword);
+ cv.put(BrowserContract.Bookmarks.PARENT, rec.androidParentID);
+ cv.put(BrowserContract.Bookmarks.POSITION, rec.androidPosition);
+
+ // Note that we don't set the modified timestamp: we allow the
+ // content provider to do that for us.
+ return cv;
+ }
+
+ /**
+ * Returns a cursor over non-deleted records that list the given androidID as a parent.
+ */
+ public Cursor getChildren(long androidID) throws NullCursorException {
+ return getChildren(androidID, false);
+ }
+
+ /**
+ * Returns a cursor with any records that list the given androidID as a parent.
+ * Excludes 'places', and optionally any deleted records.
+ */
+ public Cursor getChildren(long androidID, boolean includeDeleted) throws NullCursorException {
+ final String where = BrowserContract.Bookmarks.PARENT + " = ? AND " +
+ BrowserContract.SyncColumns.GUID + " <> ? " +
+ (!includeDeleted ? ("AND " + BrowserContract.SyncColumns.IS_DELETED + " = 0") : "");
+
+ final String[] args = new String[] { String.valueOf(androidID), "places" };
+
+ // Order by position, falling back on creation date and ID.
+ final String order = BrowserContract.Bookmarks.POSITION + ", " +
+ BrowserContract.SyncColumns.DATE_CREATED + ", " +
+ BrowserContract.Bookmarks._ID;
+ return queryHelper.safeQuery(".getChildren", getAllColumns(), where, args, order);
+ }
+
+
+ @Override
+ protected String[] getAllColumns() {
+ return BrowserContractHelpers.BookmarkColumns;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepository.java
new file mode 100644
index 000000000..38520fd7a
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepository.java
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import org.mozilla.gecko.sync.repositories.BookmarksRepository;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+
+import android.content.Context;
+
+public class AndroidBrowserBookmarksRepository extends AndroidBrowserRepository implements BookmarksRepository {
+
+ @Override
+ protected void sessionCreator(RepositorySessionCreationDelegate delegate, Context context) {
+ AndroidBrowserBookmarksRepositorySession session = new AndroidBrowserBookmarksRepositorySession(AndroidBrowserBookmarksRepository.this, context);
+ final RepositorySessionCreationDelegate deferredCreationDelegate = delegate.deferredCreationDelegate();
+ deferredCreationDelegate.onSessionCreated(session);
+ }
+
+ @Override
+ protected AndroidBrowserRepositoryDataAccessor getDataAccessor(Context context) {
+ return new AndroidBrowserBookmarksDataAccessor(context);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepositorySession.java
new file mode 100644
index 000000000..fb79901a1
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserBookmarksRepositorySession.java
@@ -0,0 +1,1107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.TreeMap;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
+import org.mozilla.gecko.sync.repositories.NoGuidForIdException;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.ParentNotFoundException;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import android.content.ContentUris;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+
+public class AndroidBrowserBookmarksRepositorySession extends AndroidBrowserRepositorySession
+ implements BookmarksInsertionManager.BookmarkInserter {
+
+ public static final int DEFAULT_DELETION_FLUSH_THRESHOLD = 50;
+ public static final int DEFAULT_INSERTION_FLUSH_THRESHOLD = 50;
+
+ // TODO: synchronization for these.
+ private final HashMap<String, Long> parentGuidToIDMap = new HashMap<String, Long>();
+ private final HashMap<Long, String> parentIDToGuidMap = new HashMap<Long, String>();
+
+ /**
+ * Some notes on reparenting/reordering.
+ *
+ * Fennec stores new items with a high-negative position, because it doesn't care.
+ * On the other hand, it also doesn't give us any help managing positions.
+ *
+ * We can process records and folders in any order, though we'll usually see folders
+ * first because their sortindex is larger.
+ *
+ * We can also see folders that refer to children we haven't seen, and children we
+ * won't see (perhaps due to a TTL, perhaps due to a limit on our fetch).
+ *
+ * And of course folders can refer to local children (including ones that might
+ * be reconciled into oblivion!), or local children in other folders. And the local
+ * version of a folder -- which might be a reconciling target, or might not -- can
+ * have local additions or removals. (That causes complications with on-the-fly
+ * reordering: we don't know in advance which records will even exist by the end
+ * of the sync.)
+ *
+ * We opt to leave records in a reasonable state as we go, applying reordering/
+ * reparenting operations whenever possible. A final sequence is applied after all
+ * incoming records have been handled.
+ *
+ * As such, we need to track a bunch of stuff as we go:
+ *
+ * • For each downloaded folder, the array of children. These will be server GUIDs,
+ * but not necessarily identical to the remote list: if we download a record and
+ * it's been locally moved, it must be removed from this child array.
+ *
+ * This mapping can be discarded when final reordering has occurred, either on
+ * store completion or when every child has been seen within this session.
+ *
+ * • A list of orphans: records whose parent folder does not yet exist. This can be
+ * trimmed as orphans are reparented.
+ *
+ * • Mappings from folder GUIDs to folder IDs, so that we can parent items without
+ * having to look in the DB. Of course, this must be kept up-to-date as we
+ * reconcile.
+ *
+ * Reordering also needs to occur during fetch. That is, a folder might have been
+ * created locally, or modified locally without any remote changes. An order must
+ * be generated for the folder's children array, and it must be persisted into the
+ * database to act as a starting point for future changes. But of course we don't
+ * want to incur a database write if the children already have a satisfactory order.
+ *
+ * Do we also need a list of "adopters", parents that are still waiting for children?
+ * As items get picked out of the orphans list, we can do on-the-fly ordering, until
+ * we're left with lonely records at the end.
+ *
+ * As we modify local folders, perhaps by moving children out of their purview, we
+ * must bump their modification time so as to cause them to be uploaded on the next
+ * stage of syncing. The same applies to simple reordering.
+ */
+
+ // TODO: can we guarantee serial access to these?
+ private final HashMap<String, ArrayList<String>> missingParentToChildren = new HashMap<String, ArrayList<String>>();
+ private final HashMap<String, JSONArray> parentToChildArray = new HashMap<String, JSONArray>();
+ private int needsReparenting = 0;
+
+ private final AndroidBrowserBookmarksDataAccessor dataAccessor;
+
+ protected BookmarksDeletionManager deletionManager;
+ protected BookmarksInsertionManager insertionManager;
+
+ /**
+ * An array of known-special GUIDs.
+ */
+ public static final String[] SPECIAL_GUIDS = new String[] {
+ // Mobile and desktop places roots have to come first.
+ "places",
+ "mobile",
+ "toolbar",
+ "menu",
+ "unfiled"
+ };
+
+ /**
+ * = A note about folder mapping =
+ *
+ * Note that _none_ of Places's folders actually have a special GUID. They're all
+ * randomly generated. Special folders are indicated by membership in the
+ * moz_bookmarks_roots table, and by having the parent `1`.
+ *
+ * Additionally, the mobile root is annotated. In Firefox Sync, PlacesUtils is
+ * used to find the IDs of these special folders.
+ *
+ * We need to consume records with these various GUIDs, producing a local
+ * representation which we are able to stably map upstream.
+ *
+ * Android Sync skips over the contents of some special GUIDs -- `places`, `tags`,
+ * etc. -- when finding IDs.
+ * Some of these special GUIDs are part of desktop structure (places, tags). Some
+ * are part of Fennec's custom data (readinglist, pinned).
+ *
+ * We don't want to upload or apply these records.
+ *
+ * That is:
+ *
+ * * We should not upload a `places`,`tags`, `readinglist`, or `pinned` record.
+ * * We can stably _store_ menu/toolbar/unfiled/mobile as special GUIDs, and set
+ * their parent ID as appropriate on upload.
+ *
+ * Fortunately, Fennec stores our representation of the data, not Places: that is,
+ * there's a "places" root, containing "mobile", "menu", "toolbar", etc.
+ *
+ * These are guaranteed to exist when the database is created.
+ *
+ * = Places folders =
+ *
+ * guid root_name folder_id parent
+ * ---------- ---------- ---------- ----------
+ * ? places 1 0
+ * ? menu 2 1
+ * ? toolbar 3 1
+ * ? tags 4 1
+ * ? unfiled 5 1
+ *
+ * ? mobile* 474 1
+ *
+ *
+ * = Fennec folders =
+ *
+ * guid folder_id parent
+ * ---------- ---------- ----------
+ * places 0 0
+ * mobile 1 0
+ * menu 2 0
+ * etc.
+ *
+ */
+ public static final Map<String, String> SPECIAL_GUID_PARENTS;
+ static {
+ HashMap<String, String> m = new HashMap<String, String>();
+ m.put("places", null);
+ m.put("menu", "places");
+ m.put("toolbar", "places");
+ m.put("tags", "places");
+ m.put("unfiled", "places");
+ m.put("mobile", "places");
+ SPECIAL_GUID_PARENTS = Collections.unmodifiableMap(m);
+ }
+
+
+ /**
+ * A map of guids to their localized name strings.
+ */
+ // Oh, if only we could make this final and initialize it in the static initializer.
+ public static Map<String, String> SPECIAL_GUIDS_MAP;
+
+ /**
+ * Return true if the provided record GUID should be skipped
+ * in child lists or fetch results.
+ *
+ * @param recordGUID the GUID of the record to check.
+ * @return true if the record should be skipped.
+ */
+ public static boolean forbiddenGUID(final String recordGUID) {
+ return recordGUID == null ||
+ BrowserContract.Bookmarks.PINNED_FOLDER_GUID.equals(recordGUID) ||
+ BrowserContract.Bookmarks.PLACES_FOLDER_GUID.equals(recordGUID) ||
+ BrowserContract.Bookmarks.TAGS_FOLDER_GUID.equals(recordGUID);
+ }
+
+ /**
+ * Return true if the provided parent GUID's children should
+ * be skipped in child lists or fetch results.
+ * This differs from {@link #forbiddenGUID(String)} in that we're skipping
+ * part of the hierarchy.
+ *
+ * @param parentGUID the GUID of parent of the record to check.
+ * @return true if the record should be skipped.
+ */
+ public static boolean forbiddenParent(final String parentGUID) {
+ return parentGUID == null ||
+ BrowserContract.Bookmarks.PINNED_FOLDER_GUID.equals(parentGUID);
+ }
+
+ public AndroidBrowserBookmarksRepositorySession(Repository repository, Context context) {
+ super(repository);
+
+ if (SPECIAL_GUIDS_MAP == null) {
+ HashMap<String, String> m = new HashMap<String, String>();
+
+ // Note that we always use the literal name "mobile" for the Mobile Bookmarks
+ // folder, regardless of its actual name in the database or the Fennec UI.
+ // This is to match desktop (working around Bug 747699) and to avoid a similar
+ // issue locally. See Bug 748898.
+ m.put("mobile", "mobile");
+
+ // Other folders use their contextualized names, and we simply rely on
+ // these not changing, matching desktop, and such to avoid issues.
+ m.put("menu", context.getString(R.string.bookmarks_folder_menu));
+ m.put("places", context.getString(R.string.bookmarks_folder_places));
+ m.put("toolbar", context.getString(R.string.bookmarks_folder_toolbar));
+ m.put("unfiled", context.getString(R.string.bookmarks_folder_unfiled));
+
+ SPECIAL_GUIDS_MAP = Collections.unmodifiableMap(m);
+ }
+
+ dbHelper = new AndroidBrowserBookmarksDataAccessor(context);
+ dataAccessor = (AndroidBrowserBookmarksDataAccessor) dbHelper;
+ }
+
+ private static int getTypeFromCursor(Cursor cur) {
+ return RepoUtils.getIntFromCursor(cur, BrowserContract.Bookmarks.TYPE);
+ }
+
+ private static boolean rowIsFolder(Cursor cur) {
+ return getTypeFromCursor(cur) == BrowserContract.Bookmarks.TYPE_FOLDER;
+ }
+
+ private String getGUIDForID(long androidID) {
+ String guid = parentIDToGuidMap.get(androidID);
+ trace(" " + androidID + " => " + guid);
+ return guid;
+ }
+
+ private long getIDForGUID(String guid) {
+ Long id = parentGuidToIDMap.get(guid);
+ if (id == null) {
+ Logger.warn(LOG_TAG, "Couldn't find local ID for GUID " + guid);
+ return -1;
+ }
+ return id;
+ }
+
+ private String getGUID(Cursor cur) {
+ return RepoUtils.getStringFromCursor(cur, "guid");
+ }
+
+ private long getParentID(Cursor cur) {
+ return RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.PARENT);
+ }
+
+ // More efficient for bulk operations.
+ private long getPosition(Cursor cur, int positionIndex) {
+ return cur.getLong(positionIndex);
+ }
+ private long getPosition(Cursor cur) {
+ return RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.POSITION);
+ }
+
+ private String getParentName(String parentGUID) throws ParentNotFoundException, NullCursorException {
+ if (parentGUID == null) {
+ return "";
+ }
+ if (SPECIAL_GUIDS_MAP.containsKey(parentGUID)) {
+ return SPECIAL_GUIDS_MAP.get(parentGUID);
+ }
+
+ // Get parent name from database.
+ String parentName = "";
+ Cursor name = dataAccessor.fetch(new String[] { parentGUID });
+ try {
+ name.moveToFirst();
+ if (!name.isAfterLast()) {
+ parentName = RepoUtils.getStringFromCursor(name, BrowserContract.Bookmarks.TITLE);
+ }
+ else {
+ Logger.error(LOG_TAG, "Couldn't find record with guid '" + parentGUID + "' when looking for parent name.");
+ throw new ParentNotFoundException(null);
+ }
+ } finally {
+ name.close();
+ }
+ return parentName;
+ }
+
+ /**
+ * Retrieve the child array for a record, repositioning and updating the database as necessary.
+ *
+ * @param folderID
+ * The database ID of the folder.
+ * @param persist
+ * True if generated positions should be written to the database. The modified
+ * time of the parent folder is only bumped if this is true.
+ * @param childArray
+ * A new, empty JSONArray which will be populated with an array of GUIDs.
+ * @return
+ * True if the resulting array is "clean" (i.e., reflects the content of the database).
+ * @throws NullCursorException
+ */
+ @SuppressWarnings("unchecked")
+ private boolean getChildrenArray(long folderID, boolean persist, JSONArray childArray) throws NullCursorException {
+ trace("Calling getChildren for androidID " + folderID);
+ Cursor children = dataAccessor.getChildren(folderID);
+ try {
+ if (!children.moveToFirst()) {
+ trace("No children: empty cursor.");
+ return true;
+ }
+ final int positionIndex = children.getColumnIndex(BrowserContract.Bookmarks.POSITION);
+ final int count = children.getCount();
+ Logger.debug(LOG_TAG, "Expecting " + count + " children.");
+
+ // Sorted by requested position.
+ TreeMap<Long, ArrayList<String>> guids = new TreeMap<Long, ArrayList<String>>();
+
+ while (!children.isAfterLast()) {
+ final String childGuid = getGUID(children);
+ final long childPosition = getPosition(children, positionIndex);
+ trace(" Child GUID: " + childGuid);
+ trace(" Child position: " + childPosition);
+ Utils.addToIndexBucketMap(guids, Math.abs(childPosition), childGuid);
+ children.moveToNext();
+ }
+
+ // This will suffice for taking a jumble of records and indices and
+ // producing a sorted sequence that preserves some kind of order --
+ // from the abs of the position, falling back on cursor order (that
+ // is, creation time and ID).
+ // Note that this code is not intended to merge values from two sources!
+ boolean changed = false;
+ int i = 0;
+ for (Entry<Long, ArrayList<String>> entry : guids.entrySet()) {
+ long pos = entry.getKey();
+ int atPos = entry.getValue().size();
+
+ // If every element has a different index, and the indices are
+ // in strict natural order, then changed will be false.
+ if (atPos > 1 || pos != i) {
+ changed = true;
+ }
+
+ ++i;
+
+ for (String guid : entry.getValue()) {
+ if (!forbiddenGUID(guid)) {
+ childArray.add(guid);
+ }
+ }
+ }
+
+ if (Logger.shouldLogVerbose(LOG_TAG)) {
+ // Don't JSON-encode unless we're logging.
+ Logger.trace(LOG_TAG, "Output child array: " + childArray.toJSONString());
+ }
+
+ if (!changed) {
+ Logger.debug(LOG_TAG, "Nothing moved! Database reflects child array.");
+ return true;
+ }
+
+ if (!persist) {
+ Logger.debug(LOG_TAG, "Returned array does not match database, and not persisting.");
+ return false;
+ }
+
+ Logger.debug(LOG_TAG, "Generating child array required moving records. Updating DB.");
+ final long time = now();
+ if (0 < dataAccessor.updatePositions(childArray)) {
+ Logger.debug(LOG_TAG, "Bumping parent time to " + time + ".");
+ dataAccessor.bumpModified(folderID, time);
+ }
+ return true;
+ } finally {
+ children.close();
+ }
+ }
+
+ protected static boolean isDeleted(Cursor cur) {
+ return RepoUtils.getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) != 0;
+ }
+
+ @Override
+ protected Record retrieveDuringStore(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
+ // During storing of a retrieved record, we never care about the children
+ // array that's already present in the database -- we don't use it for
+ // reconciling. Skip all that effort for now.
+ return retrieveRecord(cur, false);
+ }
+
+ @Override
+ protected Record retrieveDuringFetch(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
+ return retrieveRecord(cur, true);
+ }
+
+ /**
+ * Build a record from a cursor, with a flag to dictate whether the
+ * children array should be computed and written back into the database.
+ */
+ protected BookmarkRecord retrieveRecord(Cursor cur, boolean computeAndPersistChildren) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
+ String recordGUID = getGUID(cur);
+ Logger.trace(LOG_TAG, "Record from mirror cursor: " + recordGUID);
+
+ if (forbiddenGUID(recordGUID)) {
+ Logger.debug(LOG_TAG, "Ignoring " + recordGUID + " record in recordFromMirrorCursor.");
+ return null;
+ }
+
+ // Short-cut for deleted items.
+ if (isDeleted(cur)) {
+ return AndroidBrowserBookmarksRepositorySession.bookmarkFromMirrorCursor(cur, null, null, null);
+ }
+
+ long androidParentID = getParentID(cur);
+
+ // Ensure special folders stay in the right place.
+ String androidParentGUID = SPECIAL_GUID_PARENTS.get(recordGUID);
+ if (androidParentGUID == null) {
+ androidParentGUID = getGUIDForID(androidParentID);
+ }
+
+ boolean needsReparenting = false;
+
+ if (androidParentGUID == null) {
+ Logger.debug(LOG_TAG, "No parent GUID for record " + recordGUID + " with parent " + androidParentID);
+ // If the parent has been stored and somehow has a null GUID, throw an error.
+ if (parentIDToGuidMap.containsKey(androidParentID)) {
+ Logger.error(LOG_TAG, "Have the parent android ID for the record but the parent's GUID wasn't found.");
+ throw new NoGuidForIdException(null);
+ }
+
+ // We have a parent ID but it's wrong. If the record is deleted,
+ // we'll just say that it was in the Unsorted Bookmarks folder.
+ // If not, we'll move it into Mobile Bookmarks.
+ needsReparenting = true;
+ }
+
+ // If record is a folder, and we want to see children at this time, then build out the children array.
+ final JSONArray childArray;
+ if (computeAndPersistChildren) {
+ childArray = getChildrenArrayForRecordCursor(cur, recordGUID, true);
+ } else {
+ childArray = null;
+ }
+ String parentName = getParentName(androidParentGUID);
+ BookmarkRecord bookmark = AndroidBrowserBookmarksRepositorySession.bookmarkFromMirrorCursor(cur, androidParentGUID, parentName, childArray);
+
+ if (bookmark == null) {
+ Logger.warn(LOG_TAG, "Unable to extract bookmark from cursor. Record GUID " + recordGUID +
+ ", parent " + androidParentGUID + "/" + androidParentID);
+ return null;
+ }
+
+ if (needsReparenting) {
+ Logger.warn(LOG_TAG, "Bookmark record " + recordGUID + " has a bad parent pointer. Reparenting now.");
+
+ String destination = bookmark.deleted ? "unfiled" : "mobile";
+ bookmark.androidParentID = getIDForGUID(destination);
+ bookmark.androidPosition = getPosition(cur);
+ bookmark.parentID = destination;
+ bookmark.parentName = getParentName(destination);
+ if (!bookmark.deleted) {
+ // Actually move it.
+ // TODO: compute position. Persist.
+ relocateBookmark(bookmark);
+ }
+ }
+
+ return bookmark;
+ }
+
+ /**
+ * Ensure that the local database row for the provided bookmark
+ * reflects this record's parent information.
+ *
+ * @param bookmark
+ */
+ private void relocateBookmark(BookmarkRecord bookmark) {
+ dataAccessor.updateParentAndPosition(bookmark.guid, bookmark.androidParentID, bookmark.androidPosition);
+ }
+
+ protected JSONArray getChildrenArrayForRecordCursor(Cursor cur, String recordGUID, boolean persist) throws NullCursorException {
+ boolean isFolder = rowIsFolder(cur);
+ if (!isFolder) {
+ return null;
+ }
+
+ long androidID = parentGuidToIDMap.get(recordGUID);
+ JSONArray childArray = new JSONArray();
+ getChildrenArray(androidID, persist, childArray);
+
+ Logger.debug(LOG_TAG, "Fetched " + childArray.size() + " children for " + recordGUID);
+ return childArray;
+ }
+
+ @Override
+ public boolean shouldIgnore(Record record) {
+ if (!(record instanceof BookmarkRecord)) {
+ return true;
+ }
+ if (record.deleted) {
+ return false;
+ }
+
+ BookmarkRecord bmk = (BookmarkRecord) record;
+
+ if (forbiddenGUID(bmk.guid)) {
+ Logger.debug(LOG_TAG, "Ignoring forbidden record with guid: " + bmk.guid);
+ return true;
+ }
+
+ if (forbiddenParent(bmk.parentID)) {
+ Logger.debug(LOG_TAG, "Ignoring child " + bmk.guid + " of forbidden parent folder " + bmk.parentID);
+ return true;
+ }
+
+ if (BrowserContractHelpers.isSupportedType(bmk.type)) {
+ return false;
+ }
+
+ Logger.debug(LOG_TAG, "Ignoring record with guid: " + bmk.guid + " and type: " + bmk.type);
+ return true;
+ }
+
+ @Override
+ public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
+ // Check for the existence of special folders
+ // and insert them if they don't exist.
+ Cursor cur;
+ try {
+ Logger.debug(LOG_TAG, "Check and build special GUIDs.");
+ dataAccessor.checkAndBuildSpecialGuids();
+ cur = dataAccessor.getGuidsIDsForFolders();
+ Logger.debug(LOG_TAG, "Got GUIDs for folders.");
+ } catch (android.database.sqlite.SQLiteConstraintException e) {
+ Logger.error(LOG_TAG, "Got sqlite constraint exception working with Fennec bookmark DB.", e);
+ delegate.onBeginFailed(e);
+ return;
+ } catch (Exception e) {
+ delegate.onBeginFailed(e);
+ return;
+ }
+
+ // To deal with parent mapping of bookmarks we have to do some
+ // hairy stuff. Here's the setup for it.
+
+ Logger.debug(LOG_TAG, "Preparing folder ID mappings.");
+
+ // Fake our root.
+ Logger.debug(LOG_TAG, "Tracking places root as ID 0.");
+ parentIDToGuidMap.put(0L, "places");
+ parentGuidToIDMap.put("places", 0L);
+ try {
+ cur.moveToFirst();
+ while (!cur.isAfterLast()) {
+ String guid = getGUID(cur);
+ long id = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks._ID);
+ parentGuidToIDMap.put(guid, id);
+ parentIDToGuidMap.put(id, guid);
+ Logger.debug(LOG_TAG, "GUID " + guid + " maps to " + id);
+ cur.moveToNext();
+ }
+ } finally {
+ cur.close();
+ }
+ deletionManager = new BookmarksDeletionManager(dataAccessor, DEFAULT_DELETION_FLUSH_THRESHOLD);
+
+ // We just crawled the database enumerating all folders; we'll start the
+ // insertion manager with exactly these folders as the known parents (the
+ // collection is copied) in the manager constructor.
+ insertionManager = new BookmarksInsertionManager(DEFAULT_INSERTION_FLUSH_THRESHOLD, parentGuidToIDMap.keySet(), this);
+
+ Logger.debug(LOG_TAG, "Done with initial setup of bookmarks session.");
+ super.begin(delegate);
+ }
+
+ /**
+ * Implement method of BookmarksInsertionManager.BookmarkInserter.
+ */
+ @Override
+ public boolean insertFolder(BookmarkRecord record) {
+ // A folder that is *not* deleted needs its androidID updated, so that
+ // updateBookkeeping can re-parent, etc.
+ Record toStore = prepareRecord(record);
+ try {
+ Uri recordURI = dbHelper.insert(toStore);
+ if (recordURI == null) {
+ delegate.onRecordStoreFailed(new RuntimeException("Got null URI inserting folder with guid " + toStore.guid + "."), record.guid);
+ return false;
+ }
+ toStore.androidID = ContentUris.parseId(recordURI);
+ Logger.debug(LOG_TAG, "Inserted folder with guid " + toStore.guid + " as androidID " + toStore.androidID);
+
+ updateBookkeeping(toStore);
+ } catch (Exception e) {
+ delegate.onRecordStoreFailed(e, record.guid);
+ return false;
+ }
+ trackRecord(toStore);
+ delegate.onRecordStoreSucceeded(record.guid);
+ return true;
+ }
+
+ /**
+ * Implement method of BookmarksInsertionManager.BookmarkInserter.
+ */
+ @Override
+ public void bulkInsertNonFolders(Collection<BookmarkRecord> records) {
+ // All of these records are *not* deleted and *not* folders, so we don't
+ // need to update androidID at all!
+ // TODO: persist records that fail to insert for later retry.
+ ArrayList<Record> toStores = new ArrayList<Record>(records.size());
+ for (Record record : records) {
+ toStores.add(prepareRecord(record));
+ }
+
+ try {
+ int stored = dataAccessor.bulkInsert(toStores);
+ if (stored != toStores.size()) {
+ // Something failed; most pessimistic action is to declare that all insertions failed.
+ // TODO: perform the bulkInsert in a transaction and rollback unless all insertions succeed?
+ for (Record failed : toStores) {
+ delegate.onRecordStoreFailed(new RuntimeException("Possibly failed to bulkInsert non-folder with guid " + failed.guid + "."), failed.guid);
+ }
+ return;
+ }
+ } catch (NullCursorException e) {
+ for (Record failed : toStores) {
+ delegate.onRecordStoreFailed(e, failed.guid);
+ }
+ return;
+ }
+
+ // Success For All!
+ for (Record succeeded : toStores) {
+ try {
+ updateBookkeeping(succeeded);
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception updating bookkeeping of non-folder with guid " + succeeded.guid + ".", e);
+ }
+ trackRecord(succeeded);
+ delegate.onRecordStoreSucceeded(succeeded.guid);
+ }
+ }
+
+ @Override
+ public void finish(RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
+ // Allow these to be GCed.
+ deletionManager = null;
+ insertionManager = null;
+
+ // Override finish to do this check; make sure all records
+ // needing re-parenting have been re-parented.
+ if (needsReparenting != 0) {
+ Logger.error(LOG_TAG, "Finish called but " + needsReparenting +
+ " bookmark(s) have been placed in unsorted bookmarks and not been reparented.");
+
+ // TODO: handling of failed reparenting.
+ // E.g., delegate.onFinishFailed(new BookmarkNeedsReparentingException(null));
+ }
+ super.finish(delegate);
+ };
+
+ @Override
+ public void setStoreDelegate(RepositorySessionStoreDelegate delegate) {
+ super.setStoreDelegate(delegate);
+
+ if (deletionManager != null) {
+ deletionManager.setDelegate(delegate);
+ }
+ }
+
+ @Override
+ protected Record reconcileRecords(Record remoteRecord, Record localRecord,
+ long lastRemoteRetrieval,
+ long lastLocalRetrieval) {
+
+ BookmarkRecord reconciled = (BookmarkRecord) super.reconcileRecords(remoteRecord, localRecord,
+ lastRemoteRetrieval,
+ lastLocalRetrieval);
+
+ // For now we *always* use the remote record's children array as a starting point.
+ // We won't write it into the database yet; we'll record it and process as we go.
+ reconciled.children = ((BookmarkRecord) remoteRecord).children;
+
+ // *Always* track folders, though: if we decide we need to reposition items, we'll
+ // untrack later.
+ if (reconciled.isFolder()) {
+ trackRecord(reconciled);
+ }
+ return reconciled;
+ }
+
+ /**
+ * Rename mobile folders to "mobile", both in and out. The other half of
+ * this logic lives in {@link #computeParentFields(BookmarkRecord, String, String)}, where
+ * the parent name of a record is set from {@link #SPECIAL_GUIDS_MAP} rather than
+ * from source data.
+ *
+ * Apply this approach generally for symmetry.
+ */
+ @Override
+ protected void fixupRecord(Record record) {
+ final BookmarkRecord r = (BookmarkRecord) record;
+ final String parentName = SPECIAL_GUIDS_MAP.get(r.parentID);
+ if (parentName == null) {
+ return;
+ }
+ if (Logger.shouldLogVerbose(LOG_TAG)) {
+ Logger.trace(LOG_TAG, "Replacing parent name \"" + r.parentName + "\" with \"" + parentName + "\".");
+ }
+ r.parentName = parentName;
+ }
+
+ @Override
+ protected Record prepareRecord(Record record) {
+ if (record.deleted) {
+ Logger.debug(LOG_TAG, "No need to prepare deleted record " + record.guid);
+ return record;
+ }
+
+ BookmarkRecord bmk = (BookmarkRecord) record;
+
+ if (!isSpecialRecord(record)) {
+ // We never want to reparent special records.
+ handleParenting(bmk);
+ }
+
+ if (Logger.LOG_PERSONAL_INFORMATION) {
+ if (bmk.isFolder()) {
+ Logger.pii(LOG_TAG, "Inserting folder " + bmk.guid + ", " + bmk.title +
+ " with parent " + bmk.androidParentID +
+ " (" + bmk.parentID + ", " + bmk.parentName +
+ ", " + bmk.androidPosition + ")");
+ } else {
+ Logger.pii(LOG_TAG, "Inserting bookmark " + bmk.guid + ", " + bmk.title + ", " +
+ bmk.bookmarkURI + " with parent " + bmk.androidParentID +
+ " (" + bmk.parentID + ", " + bmk.parentName +
+ ", " + bmk.androidPosition + ")");
+ }
+ } else {
+ if (bmk.isFolder()) {
+ Logger.debug(LOG_TAG, "Inserting folder " + bmk.guid + ", parent " +
+ bmk.androidParentID +
+ " (" + bmk.parentID + ", " + bmk.androidPosition + ")");
+ } else {
+ Logger.debug(LOG_TAG, "Inserting bookmark " + bmk.guid + " with parent " +
+ bmk.androidParentID +
+ " (" + bmk.parentID + ", " + ", " + bmk.androidPosition + ")");
+ }
+ }
+ return bmk;
+ }
+
+ /**
+ * If the provided record doesn't have correct parent information,
+ * update appropriate bookkeeping to improve the situation.
+ *
+ * @param bmk
+ */
+ private void handleParenting(BookmarkRecord bmk) {
+ if (parentGuidToIDMap.containsKey(bmk.parentID)) {
+ bmk.androidParentID = parentGuidToIDMap.get(bmk.parentID);
+
+ // Might as well set a basic position from the downloaded children array.
+ JSONArray children = parentToChildArray.get(bmk.parentID);
+ if (children != null) {
+ int index = children.indexOf(bmk.guid);
+ if (index >= 0) {
+ bmk.androidPosition = index;
+ }
+ }
+ }
+ else {
+ bmk.androidParentID = parentGuidToIDMap.get("unfiled");
+ ArrayList<String> children;
+ if (missingParentToChildren.containsKey(bmk.parentID)) {
+ children = missingParentToChildren.get(bmk.parentID);
+ } else {
+ children = new ArrayList<String>();
+ }
+ children.add(bmk.guid);
+ needsReparenting++;
+ missingParentToChildren.put(bmk.parentID, children);
+ }
+ }
+
+ private boolean isSpecialRecord(Record record) {
+ return SPECIAL_GUID_PARENTS.containsKey(record.guid);
+ }
+
+ @Override
+ protected void updateBookkeeping(Record record) throws NoGuidForIdException,
+ NullCursorException,
+ ParentNotFoundException {
+ super.updateBookkeeping(record);
+ BookmarkRecord bmk = (BookmarkRecord) record;
+
+ // If record is folder, update maps and re-parent children if necessary.
+ if (!bmk.isFolder()) {
+ Logger.debug(LOG_TAG, "Not a folder. No bookkeeping.");
+ return;
+ }
+
+ Logger.debug(LOG_TAG, "Updating bookkeeping for folder " + record.guid);
+
+ // Mappings between ID and GUID.
+ // TODO: update our persisted children arrays!
+ // TODO: if our Android ID just changed, replace parents for all of our children.
+ parentGuidToIDMap.put(bmk.guid, bmk.androidID);
+ parentIDToGuidMap.put(bmk.androidID, bmk.guid);
+
+ JSONArray childArray = bmk.children;
+
+ if (Logger.shouldLogVerbose(LOG_TAG)) {
+ Logger.trace(LOG_TAG, bmk.guid + " has children " + childArray.toJSONString());
+ }
+ parentToChildArray.put(bmk.guid, childArray);
+
+ // Re-parent.
+ if (missingParentToChildren.containsKey(bmk.guid)) {
+ for (String child : missingParentToChildren.get(bmk.guid)) {
+ // This might return -1; that's OK, the bookmark will
+ // be properly repositioned later.
+ long position = childArray.indexOf(child);
+ dataAccessor.updateParentAndPosition(child, bmk.androidID, position);
+ needsReparenting--;
+ }
+ missingParentToChildren.remove(bmk.guid);
+ }
+ }
+
+ @Override
+ protected void insert(Record record) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
+ try {
+ insertionManager.enqueueRecord((BookmarkRecord) record);
+ } catch (Exception e) {
+ throw new NullCursorException(e);
+ }
+ }
+
+ @Override
+ protected void storeRecordDeletion(final Record record, final Record existingRecord) {
+ if (SPECIAL_GUIDS_MAP.containsKey(record.guid)) {
+ Logger.debug(LOG_TAG, "Told to delete record " + record.guid + ". Ignoring.");
+ return;
+ }
+ final BookmarkRecord bookmarkRecord = (BookmarkRecord) record;
+ final BookmarkRecord existingBookmark = (BookmarkRecord) existingRecord;
+ final boolean isFolder = existingBookmark.isFolder();
+ final String parentGUID = existingBookmark.parentID;
+ deletionManager.deleteRecord(bookmarkRecord.guid, isFolder, parentGUID);
+ }
+
+ protected void flushQueues() {
+ long now = now();
+ Logger.debug(LOG_TAG, "Applying remaining insertions.");
+ try {
+ insertionManager.finishUp();
+ Logger.debug(LOG_TAG, "Done applying remaining insertions.");
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Unable to apply remaining insertions.", e);
+ }
+
+ Logger.debug(LOG_TAG, "Applying deletions.");
+ try {
+ untrackGUIDs(deletionManager.flushAll(getIDForGUID("unfiled"), now));
+ Logger.debug(LOG_TAG, "Done applying deletions.");
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Unable to apply deletions.", e);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private void finishUp() {
+ try {
+ flushQueues();
+ Logger.debug(LOG_TAG, "Have " + parentToChildArray.size() + " folders whose children might need repositioning.");
+ for (Entry<String, JSONArray> entry : parentToChildArray.entrySet()) {
+ String guid = entry.getKey();
+ JSONArray onServer = entry.getValue();
+ try {
+ final long folderID = getIDForGUID(guid);
+ final JSONArray inDB = new JSONArray();
+ final boolean clean = getChildrenArray(folderID, false, inDB);
+ final boolean sameArrays = Utils.sameArrays(onServer, inDB);
+
+ // If the local children and the remote children are already
+ // the same, then we don't need to bump the modified time of the
+ // parent: we wouldn't upload a different record, so avoid the cycle.
+ if (!sameArrays) {
+ int added = 0;
+ for (Object o : inDB) {
+ if (!onServer.contains(o)) {
+ onServer.add(o);
+ added++;
+ }
+ }
+ Logger.debug(LOG_TAG, "Added " + added + " items locally.");
+ Logger.debug(LOG_TAG, "Untracking and bumping " + guid + "(" + folderID + ")");
+ dataAccessor.bumpModified(folderID, now());
+ untrackGUID(guid);
+ }
+
+ // If the arrays are different, or they're the same but not flushed to disk,
+ // write them out now.
+ if (!sameArrays || !clean) {
+ dataAccessor.updatePositions(new ArrayList<String>(onServer));
+ }
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Error repositioning children for " + guid, e);
+ }
+ }
+ } finally {
+ super.storeDone();
+ }
+ }
+
+ /**
+ * Hook into the deletion manager on wipe.
+ */
+ class BookmarkWipeRunnable extends WipeRunnable {
+ public BookmarkWipeRunnable(RepositorySessionWipeDelegate delegate) {
+ super(delegate);
+ }
+
+ @Override
+ public void run() {
+ try {
+ // Clear our queued deletions.
+ deletionManager.clear();
+ insertionManager.clear();
+ super.run();
+ } catch (Exception ex) {
+ delegate.onWipeFailed(ex);
+ return;
+ }
+ }
+ }
+
+ @Override
+ protected WipeRunnable getWipeRunnable(RepositorySessionWipeDelegate delegate) {
+ return new BookmarkWipeRunnable(delegate);
+ }
+
+ @Override
+ public void storeDone() {
+ Runnable command = new Runnable() {
+ @Override
+ public void run() {
+ finishUp();
+ }
+ };
+ storeWorkQueue.execute(command);
+ }
+
+ @Override
+ protected String buildRecordString(Record record) {
+ BookmarkRecord bmk = (BookmarkRecord) record;
+ String parent = bmk.parentName + "/";
+ if (bmk.isBookmark()) {
+ return "b" + parent + bmk.bookmarkURI + ":" + bmk.title;
+ }
+ if (bmk.isFolder()) {
+ return "f" + parent + bmk.title;
+ }
+ if (bmk.isSeparator()) {
+ return "s" + parent + bmk.androidPosition;
+ }
+ if (bmk.isQuery()) {
+ return "q" + parent + bmk.bookmarkURI;
+ }
+ return null;
+ }
+
+ public static BookmarkRecord computeParentFields(BookmarkRecord rec, String suggestedParentGUID, String suggestedParentName) {
+ final String guid = rec.guid;
+ if (guid == null) {
+ // Oh dear.
+ Logger.error(LOG_TAG, "No guid in computeParentFields!");
+ return null;
+ }
+
+ String realParent = SPECIAL_GUID_PARENTS.get(guid);
+ if (realParent == null) {
+ // No magic parent. Use whatever the caller suggests.
+ realParent = suggestedParentGUID;
+ } else {
+ Logger.debug(LOG_TAG, "Ignoring suggested parent ID " + suggestedParentGUID +
+ " for " + guid + "; using " + realParent);
+ }
+
+ if (realParent == null) {
+ // Oh dear.
+ Logger.error(LOG_TAG, "No parent for record " + guid);
+ return null;
+ }
+
+ // Always set the parent name for special folders back to default.
+ String parentName = SPECIAL_GUIDS_MAP.get(realParent);
+ if (parentName == null) {
+ parentName = suggestedParentName;
+ }
+
+ rec.parentID = realParent;
+ rec.parentName = parentName;
+ return rec;
+ }
+
+ private static BookmarkRecord logBookmark(BookmarkRecord rec) {
+ try {
+ Logger.debug(LOG_TAG, "Returning " + (rec.deleted ? "deleted " : "") +
+ "bookmark record " + rec.guid + " (" + rec.androidID +
+ ", parent " + rec.parentID + ")");
+ if (!rec.deleted && Logger.LOG_PERSONAL_INFORMATION) {
+ Logger.pii(LOG_TAG, "> Parent name: " + rec.parentName);
+ Logger.pii(LOG_TAG, "> Title: " + rec.title);
+ Logger.pii(LOG_TAG, "> Type: " + rec.type);
+ Logger.pii(LOG_TAG, "> URI: " + rec.bookmarkURI);
+ Logger.pii(LOG_TAG, "> Position: " + rec.androidPosition);
+ if (rec.isFolder()) {
+ Logger.pii(LOG_TAG, "FOLDER: Children are " +
+ (rec.children == null ?
+ "null" :
+ rec.children.toJSONString()));
+ }
+ }
+ } catch (Exception e) {
+ Logger.debug(LOG_TAG, "Exception logging bookmark record " + rec, e);
+ }
+ return rec;
+ }
+
+ // Create a BookmarkRecord object from a cursor on a row containing a Fennec bookmark.
+ public static BookmarkRecord bookmarkFromMirrorCursor(Cursor cur, String parentGUID, String parentName, JSONArray children) {
+ final String collection = "bookmarks";
+ final String guid = RepoUtils.getStringFromCursor(cur, BrowserContract.SyncColumns.GUID);
+ final long lastModified = RepoUtils.getLongFromCursor(cur, BrowserContract.SyncColumns.DATE_MODIFIED);
+ final boolean deleted = isDeleted(cur);
+ BookmarkRecord rec = new BookmarkRecord(guid, collection, lastModified, deleted);
+
+ // No point in populating it.
+ if (deleted) {
+ return logBookmark(rec);
+ }
+
+ int rowType = getTypeFromCursor(cur);
+ String typeString = BrowserContractHelpers.typeStringForCode(rowType);
+
+ if (typeString == null) {
+ Logger.warn(LOG_TAG, "Unsupported type code " + rowType);
+ return null;
+ }
+
+ Logger.trace(LOG_TAG, "Record " + guid + " has type " + typeString);
+
+ rec.type = typeString;
+ rec.title = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.TITLE);
+ rec.bookmarkURI = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.URL);
+ rec.description = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.DESCRIPTION);
+ rec.tags = RepoUtils.getJSONArrayFromCursor(cur, BrowserContract.Bookmarks.TAGS);
+ rec.keyword = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.KEYWORD);
+
+ rec.androidID = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks._ID);
+ rec.androidPosition = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.POSITION);
+ rec.children = children;
+
+ // Need to restore the parentId since it isn't stored in content provider.
+ // We also take this opportunity to fix up parents for special folders,
+ // allowing us to map between the hierarchies used by Fennec and Places.
+ BookmarkRecord withParentFields = computeParentFields(rec, parentGUID, parentName);
+ if (withParentFields == null) {
+ // Oh dear. Something went wrong.
+ return null;
+ }
+ return logBookmark(withParentFields);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java
new file mode 100644
index 000000000..c09d64708
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java
@@ -0,0 +1,188 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.ArrayList;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.domain.HistoryRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+
+public class AndroidBrowserHistoryDataAccessor extends
+ AndroidBrowserRepositoryDataAccessor {
+
+ public AndroidBrowserHistoryDataAccessor(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected Uri getUri() {
+ return BrowserContractHelpers.HISTORY_CONTENT_URI;
+ }
+
+ @Override
+ protected ContentValues getContentValues(Record record) {
+ ContentValues cv = new ContentValues();
+ HistoryRecord rec = (HistoryRecord) record;
+ cv.put(BrowserContract.History.GUID, rec.guid);
+ cv.put(BrowserContract.History.TITLE, rec.title);
+ cv.put(BrowserContract.History.URL, rec.histURI);
+ if (rec.visits != null) {
+ JSONArray visits = rec.visits;
+ long mostRecent = getLastVisited(visits);
+
+ // Fennec stores history timestamps in milliseconds, and visit timestamps in microseconds.
+ // The rest of Sync works in microseconds. This is the conversion point for records coming form Sync.
+ cv.put(BrowserContract.History.DATE_LAST_VISITED, mostRecent / 1000);
+ cv.put(BrowserContract.History.REMOTE_DATE_LAST_VISITED, mostRecent / 1000);
+ cv.put(BrowserContract.History.VISITS, Long.toString(visits.size()));
+ }
+ return cv;
+ }
+
+ @Override
+ protected String[] getAllColumns() {
+ return BrowserContractHelpers.HistoryColumns;
+ }
+
+ @Override
+ public Uri insert(Record record) {
+ HistoryRecord rec = (HistoryRecord) record;
+
+ Logger.debug(LOG_TAG, "Storing record " + record.guid);
+ Uri newRecordUri = super.insert(record);
+
+ Logger.debug(LOG_TAG, "Storing visits for " + record.guid);
+ context.getContentResolver().bulkInsert(
+ BrowserContract.Visits.CONTENT_URI,
+ VisitsHelper.getVisitsContentValues(rec.guid, rec.visits)
+ );
+
+ return newRecordUri;
+ }
+
+ /**
+ * Given oldGUID, first updates corresponding history record with new values (super operation),
+ * and then inserts visits from the new record.
+ * Existing visits from the old record are updated on database level to point to new GUID if necessary.
+ *
+ * @param oldGUID GUID of old <code>HistoryRecord</code>
+ * @param newRecord new <code>HistoryRecord</code> to replace old one with, and insert visits from
+ */
+ @Override
+ public void update(String oldGUID, Record newRecord) {
+ // First, update existing history records with new values. This might involve changing history GUID,
+ // and thanks to ON UPDATE CASCADE clause on Visits.HISTORY_GUID foreign key, visits will be "ported over"
+ // to the new GUID.
+ super.update(oldGUID, newRecord);
+
+ // Now we need to insert any visits from the new record
+ HistoryRecord rec = (HistoryRecord) newRecord;
+ String newGUID = newRecord.guid;
+ Logger.debug(LOG_TAG, "Storing visits for " + newGUID + ", replacing " + oldGUID);
+
+ context.getContentResolver().bulkInsert(
+ BrowserContract.Visits.CONTENT_URI,
+ VisitsHelper.getVisitsContentValues(newGUID, rec.visits)
+ );
+ }
+
+ /**
+ * Insert records.
+ * <p>
+ * This inserts all the records (using <code>ContentProvider.bulkInsert</code>),
+ * then inserts all the visit information (also using <code>ContentProvider.bulkInsert</code>).
+ *
+ * @param records
+ * the records to insert.
+ * @return
+ * the number of records actually inserted.
+ * @throws NullCursorException
+ */
+ public int bulkInsert(ArrayList<HistoryRecord> records) throws NullCursorException {
+ if (records.isEmpty()) {
+ Logger.debug(LOG_TAG, "No records to insert, returning.");
+ }
+
+ int size = records.size();
+ ContentValues[] cvs = new ContentValues[size];
+ int index = 0;
+ for (Record record : records) {
+ if (record.guid == null) {
+ throw new IllegalArgumentException("Record with null GUID passed in to bulkInsert.");
+ }
+ cvs[index] = getContentValues(record);
+ index += 1;
+ }
+
+ // First update the history records.
+ int inserted = context.getContentResolver().bulkInsert(getUri(), cvs);
+ if (inserted == size) {
+ Logger.debug(LOG_TAG, "Inserted " + inserted + " records, as expected.");
+ } else {
+ Logger.debug(LOG_TAG, "Inserted " +
+ inserted + " records but expected " +
+ size + " records; continuing to update visits.");
+ }
+
+ final ContentValues remoteVisitAggregateValues = new ContentValues();
+ final Uri historyIncrementRemoteAggregateUri = getUri().buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_REMOTE_AGGREGATES, "true")
+ .build();
+ for (Record record : records) {
+ HistoryRecord rec = (HistoryRecord) record;
+ if (rec.visits != null && rec.visits.size() != 0) {
+ int remoteVisitsInserted = context.getContentResolver().bulkInsert(
+ BrowserContract.Visits.CONTENT_URI,
+ VisitsHelper.getVisitsContentValues(rec.guid, rec.visits)
+ );
+
+ // If we just inserted any visits, update remote visit aggregate values.
+ // While inserting visits, we might not insert all of rec.visits - if we already have a local
+ // visit record with matching (guid,date), we will skip that visit.
+ // Remote visits aggregate value will be incremented by number of visits inserted.
+ // Note that we don't need to set REMOTE_DATE_LAST_VISITED, because it already gets set above.
+ if (remoteVisitsInserted > 0) {
+ // Note that REMOTE_VISITS must be set before calling cr.update(...) with a URI
+ // that has PARAM_INCREMENT_REMOTE_AGGREGATES=true.
+ remoteVisitAggregateValues.put(BrowserContract.History.REMOTE_VISITS, remoteVisitsInserted);
+ context.getContentResolver().update(
+ historyIncrementRemoteAggregateUri,
+ remoteVisitAggregateValues,
+ BrowserContract.History.GUID + " = ?", new String[] {rec.guid}
+ );
+ }
+ }
+ }
+
+ return inserted;
+ }
+
+ /**
+ * Helper method used to find largest <code>VisitsHelper.SYNC_DATE_KEY</code> value in a provided JSONArray.
+ *
+ * @param visits Array of objects which will be searched.
+ * @return largest value of <code>VisitsHelper.SYNC_DATE_KEY</code>.
+ */
+ private long getLastVisited(JSONArray visits) {
+ long mostRecent = 0;
+ for (int i = 0; i < visits.size(); i++) {
+ final JSONObject visit = (JSONObject) visits.get(i);
+ long visitDate = (Long) visit.get(VisitsHelper.SYNC_DATE_KEY);
+ if (visitDate > mostRecent) {
+ mostRecent = visitDate;
+ }
+ }
+ return mostRecent;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepository.java
new file mode 100644
index 000000000..bd2b5d31f
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepository.java
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import org.mozilla.gecko.sync.repositories.HistoryRepository;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+
+import android.content.Context;
+
+public class AndroidBrowserHistoryRepository extends AndroidBrowserRepository implements HistoryRepository {
+
+ @Override
+ protected void sessionCreator(RepositorySessionCreationDelegate delegate, Context context) {
+ AndroidBrowserHistoryRepositorySession session = new AndroidBrowserHistoryRepositorySession(AndroidBrowserHistoryRepository.this, context);
+ delegate.onSessionCreated(session);
+ }
+
+ @Override
+ protected AndroidBrowserRepositoryDataAccessor getDataAccessor(Context context) {
+ return new AndroidBrowserHistoryDataAccessor(context);
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java
new file mode 100644
index 000000000..7c462abc3
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java
@@ -0,0 +1,208 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.ArrayList;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
+import org.mozilla.gecko.sync.repositories.NoGuidForIdException;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.ParentNotFoundException;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+import org.mozilla.gecko.sync.repositories.domain.HistoryRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import android.content.ContentProviderClient;
+import android.content.Context;
+import android.database.Cursor;
+import android.os.RemoteException;
+
+public class AndroidBrowserHistoryRepositorySession extends AndroidBrowserRepositorySession {
+ public static final String LOG_TAG = "ABHistoryRepoSess";
+
+ /**
+ * The number of records to queue for insertion before writing to databases.
+ */
+ public static final int INSERT_RECORD_THRESHOLD = 50;
+ public static final int RECENT_VISITS_LIMIT = 20;
+
+ public AndroidBrowserHistoryRepositorySession(Repository repository, Context context) {
+ super(repository);
+ dbHelper = new AndroidBrowserHistoryDataAccessor(context);
+ }
+
+ @Override
+ public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
+ // HACK: Fennec creates history records without a GUID. Mercilessly drop
+ // them on the floor. See Bug 739514.
+ try {
+ dbHelper.delete(BrowserContract.History.GUID + " IS NULL", null);
+ } catch (Exception e) {
+ // Ignore.
+ }
+ super.begin(delegate);
+ }
+
+ @Override
+ protected Record retrieveDuringStore(Cursor cur) {
+ return RepoUtils.historyFromMirrorCursor(cur);
+ }
+
+ @Override
+ protected Record retrieveDuringFetch(Cursor cur) {
+ return RepoUtils.historyFromMirrorCursor(cur);
+ }
+
+ @Override
+ protected String buildRecordString(Record record) {
+ HistoryRecord hist = (HistoryRecord) record;
+ return hist.histURI;
+ }
+
+ @Override
+ public boolean shouldIgnore(Record record) {
+ if (super.shouldIgnore(record)) {
+ return true;
+ }
+ if (!(record instanceof HistoryRecord)) {
+ return true;
+ }
+ HistoryRecord r = (HistoryRecord) record;
+ return !RepoUtils.isValidHistoryURI(r.histURI);
+ }
+
+ @Override
+ protected Record transformRecord(Record record) throws NullCursorException {
+ return addVisitsToRecord(record);
+ }
+
+ private Record addVisitsToRecord(Record record) throws NullCursorException {
+ Logger.debug(LOG_TAG, "Adding visits for GUID " + record.guid);
+
+ // Sync is an object store, so what we attach here will replace what's already present on the Sync servers.
+ // We upload just a recent subset of visits for each history record for space and bandwidth reasons.
+ // We chose 20 to be conservative. See Bug 1164660 for details.
+ ContentProviderClient visitsClient = dbHelper.context.getContentResolver().acquireContentProviderClient(BrowserContractHelpers.VISITS_CONTENT_URI);
+ if (visitsClient == null) {
+ throw new IllegalStateException("Could not obtain a ContentProviderClient for Visits URI");
+ }
+
+ try {
+ ((HistoryRecord) record).visits = VisitsHelper.getRecentHistoryVisitsForGUID(
+ visitsClient, record.guid, RECENT_VISITS_LIMIT);
+ } catch (RemoteException e) {
+ throw new IllegalStateException("Error while obtaining visits for a record", e);
+ } finally {
+ visitsClient.release();
+ }
+
+ return record;
+ }
+
+ @Override
+ protected Record prepareRecord(Record record) {
+ return record;
+ }
+
+ protected final Object recordsBufferMonitor = new Object();
+ protected ArrayList<HistoryRecord> recordsBuffer = new ArrayList<HistoryRecord>();
+
+ /**
+ * Queue record for insertion, possibly flushing the queue.
+ * <p>
+ * Must be called on <code>storeWorkQueue</code> thread! But this is only
+ * called from <code>store</code>, which is called on the queue thread.
+ *
+ * @param record
+ * A <code>Record</code> with a GUID that is not present locally.
+ */
+ @Override
+ protected void insert(Record record) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
+ enqueueNewRecord((HistoryRecord) prepareRecord(record));
+ }
+
+ /**
+ * Batch incoming records until some reasonable threshold is hit or storeDone
+ * is received.
+ * <p>
+ * Must be called on <code>storeWorkQueue</code> thread!
+ *
+ * @param record A <code>Record</code> with a GUID that is not present locally.
+ * @throws NullCursorException
+ */
+ protected void enqueueNewRecord(HistoryRecord record) throws NullCursorException {
+ synchronized (recordsBufferMonitor) {
+ if (recordsBuffer.size() >= INSERT_RECORD_THRESHOLD) {
+ flushNewRecords();
+ }
+ Logger.debug(LOG_TAG, "Enqueuing new record with GUID " + record.guid);
+ recordsBuffer.add(record);
+ }
+ }
+
+ /**
+ * Flush queue of incoming records to database.
+ * <p>
+ * Must be called on <code>storeWorkQueue</code> thread!
+ * <p>
+ * Must be locked by recordsBufferMonitor!
+ * @throws NullCursorException
+ */
+ protected void flushNewRecords() throws NullCursorException {
+ if (recordsBuffer.size() < 1) {
+ Logger.debug(LOG_TAG, "No records to flush, returning.");
+ return;
+ }
+
+ final ArrayList<HistoryRecord> outgoing = recordsBuffer;
+ recordsBuffer = new ArrayList<HistoryRecord>();
+ Logger.debug(LOG_TAG, "Flushing " + outgoing.size() + " records to database.");
+ // TODO: move bulkInsert to AndroidBrowserDataAccessor?
+ int inserted = ((AndroidBrowserHistoryDataAccessor) dbHelper).bulkInsert(outgoing);
+ if (inserted != outgoing.size()) {
+ // Something failed; most pessimistic action is to declare that all insertions failed.
+ // TODO: perform the bulkInsert in a transaction and rollback unless all insertions succeed?
+ for (HistoryRecord failed : outgoing) {
+ delegate.onRecordStoreFailed(new RuntimeException("Failed to insert history item with guid " + failed.guid + "."), failed.guid);
+ }
+ return;
+ }
+
+ // All good, everybody succeeded.
+ for (HistoryRecord succeeded : outgoing) {
+ try {
+ // Does not use androidID -- just GUID -> String map.
+ updateBookkeeping(succeeded);
+ } catch (NoGuidForIdException | ParentNotFoundException e) {
+ // Should not happen.
+ throw new NullCursorException(e);
+ } catch (NullCursorException e) {
+ throw e;
+ }
+ trackRecord(succeeded);
+ delegate.onRecordStoreSucceeded(succeeded.guid); // At this point, we are really inserted.
+ }
+ }
+
+ @Override
+ public void storeDone() {
+ storeWorkQueue.execute(new Runnable() {
+ @Override
+ public void run() {
+ synchronized (recordsBufferMonitor) {
+ try {
+ flushNewRecords();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Error flushing records to database.", e);
+ }
+ }
+ storeDone(System.currentTimeMillis());
+ }
+ });
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepository.java
new file mode 100644
index 000000000..6c5c661ee
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepository.java
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCleanDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+
+import android.content.Context;
+
+public abstract class AndroidBrowserRepository extends Repository {
+
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate, Context context) {
+ new CreateSessionThread(delegate, context).start();
+ }
+
+ @Override
+ public void clean(boolean success, RepositorySessionCleanDelegate delegate, Context context) {
+ // Only clean deleted records if success
+ if (success) {
+ new CleanThread(delegate, context).start();
+ }
+ }
+
+ class CleanThread extends Thread {
+ private final RepositorySessionCleanDelegate delegate;
+ private final Context context;
+
+ public CleanThread(RepositorySessionCleanDelegate delegate, Context context) {
+ if (context == null) {
+ throw new IllegalArgumentException("context is null");
+ }
+ this.delegate = delegate;
+ this.context = context;
+ }
+
+ @Override
+ public void run() {
+ try {
+ getDataAccessor(context).purgeDeleted();
+ } catch (Exception e) {
+ delegate.onCleanFailed(AndroidBrowserRepository.this, e);
+ return;
+ }
+ delegate.onCleaned(AndroidBrowserRepository.this);
+ }
+ }
+
+ protected abstract AndroidBrowserRepositoryDataAccessor getDataAccessor(Context context);
+ protected abstract void sessionCreator(RepositorySessionCreationDelegate delegate, Context context);
+
+ class CreateSessionThread extends Thread {
+ private final RepositorySessionCreationDelegate delegate;
+ private final Context context;
+
+ public CreateSessionThread(RepositorySessionCreationDelegate delegate, Context context) {
+ if (context == null) {
+ throw new IllegalArgumentException("context is null.");
+ }
+ this.delegate = delegate;
+ this.context = context;
+ }
+
+ @Override
+ public void run() {
+ sessionCreator(delegate, context);
+ }
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositoryDataAccessor.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositoryDataAccessor.java
new file mode 100644
index 000000000..138d63d4c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositoryDataAccessor.java
@@ -0,0 +1,232 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.List;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.db.CursorDumper;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+
+public abstract class AndroidBrowserRepositoryDataAccessor {
+
+ private static final String[] GUID_COLUMNS = new String[] { BrowserContract.SyncColumns.GUID };
+ protected Context context;
+ protected static String LOG_TAG = "BrowserDataAccessor";
+ protected final RepoUtils.QueryHelper queryHelper;
+
+ public AndroidBrowserRepositoryDataAccessor(Context context) {
+ this.context = context;
+ this.queryHelper = new RepoUtils.QueryHelper(context, getUri(), LOG_TAG);
+ }
+
+ protected abstract String[] getAllColumns();
+
+ /**
+ * Produce a <code>ContentValues</code> instance that represents the provided <code>Record</code>.
+ *
+ * @param record The <code>Record</code> to be converted.
+ * @return The <code>ContentValues</code> corresponding to <code>record</code>.
+ */
+ protected abstract ContentValues getContentValues(Record record);
+
+ protected abstract Uri getUri();
+
+ /**
+ * Dump all the records in raw format.
+ */
+ public void dumpDB() {
+ Cursor cur = null;
+ try {
+ cur = queryHelper.safeQuery(".dumpDB", null, null, null, null);
+ CursorDumper.dumpCursor(cur);
+ } catch (NullCursorException e) {
+ } finally {
+ if (cur != null) {
+ cur.close();
+ }
+ }
+ }
+
+ public String dateModifiedWhere(long timestamp) {
+ return BrowserContract.SyncColumns.DATE_MODIFIED + " >= " + Long.toString(timestamp);
+ }
+
+ public void delete(String where, String[] args) {
+ Uri uri = getUri();
+ context.getContentResolver().delete(uri, where, args);
+ }
+
+ public void wipe() {
+ Logger.debug(LOG_TAG, "Wiping.");
+ delete(null, null);
+ }
+
+ public void purgeDeleted() throws NullCursorException {
+ String where = BrowserContract.SyncColumns.IS_DELETED + "= 1";
+ Uri uri = getUri();
+ Logger.info(LOG_TAG, "Purging deleted from: " + uri);
+ context.getContentResolver().delete(uri, where, null);
+ }
+
+ /**
+ * Remove matching records from the database entirely, i.e., do not set a
+ * deleted flag, delete entirely.
+ *
+ * @param guid
+ * The GUID of the record to be deleted.
+ * @return The number of records deleted.
+ */
+ public int purgeGuid(String guid) {
+ String where = BrowserContract.SyncColumns.GUID + " = ?";
+ String[] args = new String[] { guid };
+
+ int deleted = context.getContentResolver().delete(getUri(), where, args);
+ if (deleted != 1) {
+ Logger.warn(LOG_TAG, "Unexpectedly deleted " + deleted + " records for guid " + guid);
+ }
+ return deleted;
+ }
+
+ public void update(String guid, Record newRecord) {
+ String where = BrowserContract.SyncColumns.GUID + " = ?";
+ String[] args = new String[] { guid };
+ ContentValues cv = getContentValues(newRecord);
+ int updated = context.getContentResolver().update(getUri(), cv, where, args);
+ if (updated != 1) {
+ Logger.warn(LOG_TAG, "Unexpectedly updated " + updated + " rows for guid " + guid);
+ }
+ }
+
+ public Uri insert(Record record) {
+ ContentValues cv = getContentValues(record);
+ return context.getContentResolver().insert(getUri(), cv);
+ }
+
+ /**
+ * Fetch all records.
+ * <p>
+ * The caller is responsible for closing the cursor.
+ *
+ * @return A cursor. You </b>must</b> close this when you're done with it.
+ * @throws NullCursorException
+ */
+ public Cursor fetchAll() throws NullCursorException {
+ return queryHelper.safeQuery(".fetchAll", getAllColumns(), null, null, null);
+ }
+
+ /**
+ * Fetch GUIDs for records modified since the provided timestamp.
+ * <p>
+ * The caller is responsible for closing the cursor.
+ *
+ * @param timestamp A timestamp in milliseconds.
+ * @return A cursor. You <b>must</b> close this when you're done with it.
+ * @throws NullCursorException
+ */
+ public Cursor getGUIDsSince(long timestamp) throws NullCursorException {
+ return queryHelper.safeQuery(".getGUIDsSince",
+ GUID_COLUMNS,
+ dateModifiedWhere(timestamp),
+ null, null);
+ }
+
+ /**
+ * Fetch records modified since the provided timestamp.
+ * <p>
+ * The caller is responsible for closing the cursor.
+ *
+ * @param timestamp A timestamp in milliseconds.
+ * @return A cursor. You <b>must</b> close this when you're done with it.
+ * @throws NullCursorException
+ */
+ public Cursor fetchSince(long timestamp) throws NullCursorException {
+ return queryHelper.safeQuery(".fetchSince",
+ getAllColumns(),
+ dateModifiedWhere(timestamp),
+ null, null);
+ }
+
+ /**
+ * Fetch records for the provided GUIDs.
+ * <p>
+ * The caller is responsible for closing the cursor.
+ *
+ * @param guids The GUIDs of the records to fetch.
+ * @return A cursor. You <b>must</b> close this when you're done with it.
+ * @throws NullCursorException
+ */
+ public Cursor fetch(String guids[]) throws NullCursorException {
+ String where = RepoUtils.computeSQLInClause(guids.length, "guid");
+ return queryHelper.safeQuery(".fetch", getAllColumns(), where, guids, null);
+ }
+
+ public void updateByGuid(String guid, ContentValues cv) {
+ String where = BrowserContract.SyncColumns.GUID + " = ?";
+ String[] args = new String[] { guid };
+
+ int updated = context.getContentResolver().update(getUri(), cv, where, args);
+ if (updated == 1) {
+ return;
+ }
+ Logger.warn(LOG_TAG, "Unexpectedly updated " + updated + " rows for guid " + guid);
+ }
+
+ /**
+ * Insert records.
+ * <p>
+ * This inserts all the records (using <code>ContentProvider.bulkInsert</code>),
+ * but does <b>not</b> update the <code>androidID</code> of each record.
+ *
+ * @param records
+ * the records to insert.
+ * @return
+ * the number of records actually inserted.
+ * @throws NullCursorException
+ */
+ public int bulkInsert(List<Record> records) throws NullCursorException {
+ if (records.isEmpty()) {
+ Logger.debug(LOG_TAG, "No records to insert, returning.");
+ }
+
+ int size = records.size();
+ ContentValues[] cvs = new ContentValues[size];
+ int index = 0;
+ for (Record record : records) {
+ try {
+ cvs[index] = getContentValues(record);
+ index += 1;
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception in getContentValues for record with guid " + record.guid, e);
+ }
+ }
+
+ if (index != size) {
+ // bulkInsert treats null ContentValues as blank rows, which we don't want
+ // to insert into the database.
+ // We expect exceptions in getContentValues to be exceedingly rare, so we
+ // re-allocate in the (rare) error case and maintain a fast path for the
+ // success case.
+ size = index;
+ }
+
+ int inserted = context.getContentResolver().bulkInsert(getUri(), cvs);
+ if (inserted == size) {
+ Logger.debug(LOG_TAG, "Inserted " + inserted + " records, as expected.");
+ } else {
+ Logger.debug(LOG_TAG, "Inserted " +
+ inserted + " records but expected " +
+ size + " records.");
+ }
+ return inserted;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositorySession.java
new file mode 100644
index 000000000..4f0da0bcc
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserRepositorySession.java
@@ -0,0 +1,792 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.ArrayList;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.InvalidRequestException;
+import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
+import org.mozilla.gecko.sync.repositories.MultipleRecordsForGuidException;
+import org.mozilla.gecko.sync.repositories.NoGuidForIdException;
+import org.mozilla.gecko.sync.repositories.NoStoreDelegateException;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.ParentNotFoundException;
+import org.mozilla.gecko.sync.repositories.ProfileDatabaseException;
+import org.mozilla.gecko.sync.repositories.RecordFilter;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.StoreTrackingRepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import android.content.ContentUris;
+import android.database.Cursor;
+import android.net.Uri;
+import android.util.SparseArray;
+
+/**
+ * You'll notice that all delegate calls *either*:
+ *
+ * - request a deferred delegate with the appropriate work queue, then
+ * make the appropriate call, or
+ * - create a Runnable which makes the appropriate call, and pushes it
+ * directly into the appropriate work queue.
+ *
+ * This is to ensure that all delegate callbacks happen off the current
+ * thread. This provides lock safety (we don't enter another method that
+ * might try to take a lock already taken in our caller), and ensures
+ * that operations take place off the main thread.
+ *
+ * Don't do both -- the two approaches are equivalent -- and certainly
+ * don't do neither unless you know what you're doing!
+ *
+ * Similarly, all store calls go through the appropriate store queue. This
+ * ensures that store() and storeDone() consequences occur before-after.
+ *
+ * @author rnewman
+ *
+ */
+public abstract class AndroidBrowserRepositorySession extends StoreTrackingRepositorySession {
+ public static final String LOG_TAG = "BrowserRepoSession";
+
+ protected AndroidBrowserRepositoryDataAccessor dbHelper;
+
+ /**
+ * In order to reconcile the "same record" with two *different* GUIDs (for
+ * example, the same bookmark created by two different clients), we maintain a
+ * mapping for each local record from a "record string" to
+ * "local record GUID".
+ * <p>
+ * The "record string" above is a "record identifying unique key" produced by
+ * <code>buildRecordString</code>.
+ * <p>
+ * Since we hash each "record string", this map may produce a false positive.
+ * In this case, we search the database for a matching record explicitly using
+ * <code>findByRecordString</code>.
+ */
+ protected SparseArray<String> recordToGuid;
+
+ public AndroidBrowserRepositorySession(Repository repository) {
+ super(repository);
+ }
+
+ /**
+ * Retrieve a record from a cursor. Act as if we don't know the final contents of
+ * the record: for example, a folder's child array might change.
+ *
+ * Return null if this record should not be processed.
+ *
+ * @throws NoGuidForIdException
+ * @throws NullCursorException
+ * @throws ParentNotFoundException
+ */
+ protected abstract Record retrieveDuringStore(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException;
+
+ /**
+ * Retrieve a record from a cursor. Ensure that the contents of the database are
+ * updated to match the record that we're constructing: for example, the children
+ * of a folder might be repositioned as we generate the folder's record.
+ *
+ * @throws NoGuidForIdException
+ * @throws NullCursorException
+ * @throws ParentNotFoundException
+ */
+ protected abstract Record retrieveDuringFetch(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException;
+
+ /**
+ * Override this to allow records to be skipped during insertion.
+ *
+ * For example, a session subclass might skip records of an unsupported type.
+ */
+ @SuppressWarnings("static-method")
+ public boolean shouldIgnore(Record record) {
+ return false;
+ }
+
+ /**
+ * Perform any necessary transformation of a record prior to searching by
+ * any field other than GUID.
+ *
+ * Example: translating remote folder names into local names.
+ */
+ @SuppressWarnings("static-method")
+ protected void fixupRecord(Record record) {
+ return;
+ }
+
+ /**
+ * Override in subclass to implement record extension.
+ *
+ * Populate any fields of the record that are expensive to calculate,
+ * prior to reconciling.
+ *
+ * Example: computing children arrays.
+ *
+ * Return null if this record should not be processed.
+ *
+ * @param record
+ * The record to transform. Can be null.
+ * @return The transformed record. Can be null.
+ * @throws NullCursorException
+ */
+ @SuppressWarnings("static-method")
+ protected Record transformRecord(Record record) throws NullCursorException {
+ return record;
+ }
+
+ @Override
+ public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
+ RepositorySessionBeginDelegate deferredDelegate = delegate.deferredBeginDelegate(delegateQueue);
+ super.sharedBegin();
+
+ try {
+ // We do this check here even though it results in one extra call to the DB
+ // because if we didn't, we have to do a check on every other call since there
+ // is no way of knowing which call would be hit first.
+ checkDatabase();
+ } catch (ProfileDatabaseException e) {
+ Logger.error(LOG_TAG, "ProfileDatabaseException from begin. Fennec must be launched once until this error is fixed");
+ deferredDelegate.onBeginFailed(e);
+ return;
+ } catch (Exception e) {
+ deferredDelegate.onBeginFailed(e);
+ return;
+ }
+ storeTracker = createStoreTracker();
+ deferredDelegate.onBeginSucceeded(this);
+ }
+
+ @Override
+ public void finish(RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
+ dbHelper = null;
+ recordToGuid = null;
+ super.finish(delegate);
+ }
+
+ /**
+ * Produce a "record string" (record identifying unique key).
+ *
+ * @param record
+ * the <code>Record</code> to identify.
+ * @return a <code>String</code> instance.
+ */
+ protected abstract String buildRecordString(Record record);
+
+ protected void checkDatabase() throws ProfileDatabaseException, NullCursorException {
+ Logger.debug(LOG_TAG, "BEGIN: checking database.");
+ try {
+ dbHelper.fetch(new String[] { "none" }).close();
+ Logger.debug(LOG_TAG, "END: checking database.");
+ } catch (NullPointerException e) {
+ throw new ProfileDatabaseException(e);
+ }
+ }
+
+ @Override
+ public void guidsSince(long timestamp, RepositorySessionGuidsSinceDelegate delegate) {
+ GuidsSinceRunnable command = new GuidsSinceRunnable(timestamp, delegate);
+ delegateQueue.execute(command);
+ }
+
+ class GuidsSinceRunnable implements Runnable {
+
+ private final RepositorySessionGuidsSinceDelegate delegate;
+ private final long timestamp;
+
+ public GuidsSinceRunnable(long timestamp,
+ RepositorySessionGuidsSinceDelegate delegate) {
+ this.timestamp = timestamp;
+ this.delegate = delegate;
+ }
+
+ @Override
+ public void run() {
+ if (!isActive()) {
+ delegate.onGuidsSinceFailed(new InactiveSessionException(null));
+ return;
+ }
+
+ Cursor cur;
+ try {
+ cur = dbHelper.getGUIDsSince(timestamp);
+ } catch (Exception e) {
+ delegate.onGuidsSinceFailed(e);
+ return;
+ }
+
+ ArrayList<String> guids;
+ try {
+ if (!cur.moveToFirst()) {
+ delegate.onGuidsSinceSucceeded(new String[] {});
+ return;
+ }
+ guids = new ArrayList<String>();
+ while (!cur.isAfterLast()) {
+ guids.add(RepoUtils.getStringFromCursor(cur, "guid"));
+ cur.moveToNext();
+ }
+ } finally {
+ Logger.debug(LOG_TAG, "Closing cursor after guidsSince.");
+ cur.close();
+ }
+
+ String guidsArray[] = new String[guids.size()];
+ guids.toArray(guidsArray);
+ delegate.onGuidsSinceSucceeded(guidsArray);
+ }
+ }
+
+ @Override
+ public void fetch(String[] guids,
+ RepositorySessionFetchRecordsDelegate delegate) throws InactiveSessionException {
+ FetchRunnable command = new FetchRunnable(guids, now(), null, delegate);
+ executeDelegateCommand(command);
+ }
+
+ abstract class FetchingRunnable implements Runnable {
+ protected final RepositorySessionFetchRecordsDelegate delegate;
+
+ public FetchingRunnable(RepositorySessionFetchRecordsDelegate delegate) {
+ this.delegate = delegate;
+ }
+
+ protected void fetchFromCursor(Cursor cursor, RecordFilter filter, long end) {
+ Logger.debug(LOG_TAG, "Fetch from cursor:");
+ try {
+ try {
+ if (!cursor.moveToFirst()) {
+ delegate.onFetchCompleted(end);
+ return;
+ }
+ while (!cursor.isAfterLast()) {
+ Record r = retrieveDuringFetch(cursor);
+ if (r != null) {
+ if (filter == null || !filter.excludeRecord(r)) {
+ Logger.trace(LOG_TAG, "Processing record " + r.guid);
+ delegate.onFetchedRecord(transformRecord(r));
+ } else {
+ Logger.debug(LOG_TAG, "Skipping filtered record " + r.guid);
+ }
+ }
+ cursor.moveToNext();
+ }
+ delegate.onFetchCompleted(end);
+ } catch (NoGuidForIdException e) {
+ Logger.warn(LOG_TAG, "No GUID for ID.", e);
+ delegate.onFetchFailed(e, null);
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Exception in fetchFromCursor.", e);
+ delegate.onFetchFailed(e, null);
+ return;
+ }
+ } finally {
+ Logger.trace(LOG_TAG, "Closing cursor after fetch.");
+ cursor.close();
+ }
+ }
+ }
+
+ public class FetchRunnable extends FetchingRunnable {
+ private final String[] guids;
+ private final long end;
+ private final RecordFilter filter;
+
+ public FetchRunnable(String[] guids,
+ long end,
+ RecordFilter filter,
+ RepositorySessionFetchRecordsDelegate delegate) {
+ super(delegate);
+ this.guids = guids;
+ this.end = end;
+ this.filter = filter;
+ }
+
+ @Override
+ public void run() {
+ if (!isActive()) {
+ delegate.onFetchFailed(new InactiveSessionException(null), null);
+ return;
+ }
+
+ if (guids == null || guids.length < 1) {
+ Logger.error(LOG_TAG, "No guids sent to fetch");
+ delegate.onFetchFailed(new InvalidRequestException(null), null);
+ return;
+ }
+
+ try {
+ Cursor cursor = dbHelper.fetch(guids);
+ this.fetchFromCursor(cursor, filter, end);
+ } catch (NullCursorException e) {
+ delegate.onFetchFailed(e, null);
+ }
+ }
+ }
+
+ @Override
+ public void fetchSince(long timestamp,
+ RepositorySessionFetchRecordsDelegate delegate) {
+ if (this.storeTracker == null) {
+ throw new IllegalStateException("Store tracker not yet initialized!");
+ }
+
+ Logger.debug(LOG_TAG, "Running fetchSince(" + timestamp + ").");
+ FetchSinceRunnable command = new FetchSinceRunnable(timestamp, now(), this.storeTracker.getFilter(), delegate);
+ delegateQueue.execute(command);
+ }
+
+ class FetchSinceRunnable extends FetchingRunnable {
+ private final long since;
+ private final long end;
+ private final RecordFilter filter;
+
+ public FetchSinceRunnable(long since,
+ long end,
+ RecordFilter filter,
+ RepositorySessionFetchRecordsDelegate delegate) {
+ super(delegate);
+ this.since = since;
+ this.end = end;
+ this.filter = filter;
+ }
+
+ @Override
+ public void run() {
+ if (!isActive()) {
+ delegate.onFetchFailed(new InactiveSessionException(null), null);
+ return;
+ }
+
+ try {
+ Cursor cursor = dbHelper.fetchSince(since);
+ this.fetchFromCursor(cursor, filter, end);
+ } catch (NullCursorException e) {
+ delegate.onFetchFailed(e, null);
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) {
+ this.fetchSince(0, delegate);
+ }
+
+ protected int storeCount = 0;
+
+ @Override
+ public void store(final Record record) throws NoStoreDelegateException {
+ if (delegate == null) {
+ throw new NoStoreDelegateException();
+ }
+ if (record == null) {
+ Logger.error(LOG_TAG, "Record sent to store was null");
+ throw new IllegalArgumentException("Null record passed to AndroidBrowserRepositorySession.store().");
+ }
+
+ storeCount += 1;
+ Logger.debug(LOG_TAG, "Storing record with GUID " + record.guid + " (stored " + storeCount + " records this session).");
+
+ // Store Runnables *must* complete synchronously. It's OK, they
+ // run on a background thread.
+ Runnable command = new Runnable() {
+
+ @Override
+ public void run() {
+ if (!isActive()) {
+ Logger.warn(LOG_TAG, "AndroidBrowserRepositorySession is inactive. Store failing.");
+ delegate.onRecordStoreFailed(new InactiveSessionException(null), record.guid);
+ return;
+ }
+
+ // Check that the record is a valid type.
+ // Fennec only supports bookmarks and folders. All other types of records,
+ // including livemarks and queries, are simply ignored.
+ // See Bug 708149. This might be resolved by Fennec changing its database
+ // schema, or by Sync storing non-applied records in its own private database.
+ if (shouldIgnore(record)) {
+ Logger.debug(LOG_TAG, "Ignoring record " + record.guid);
+
+ // Don't throw: we don't want to abort the entire sync when we get a livemark!
+ // delegate.onRecordStoreFailed(new InvalidBookmarkTypeException(null));
+ return;
+ }
+
+
+ // TODO: lift these into the session.
+ // Temporary: this matches prior syncing semantics, in which only
+ // the relationship between the local and remote record is considered.
+ // In the future we'll track these two timestamps and use them to
+ // determine which records have changed, and thus process incoming
+ // records more efficiently.
+ long lastLocalRetrieval = 0; // lastSyncTimestamp?
+ long lastRemoteRetrieval = 0; // TODO: adjust for clock skew.
+ boolean remotelyModified = record.lastModified > lastRemoteRetrieval;
+
+ Record existingRecord;
+ try {
+ // GUID matching only: deleted records don't have a payload with which to search.
+ existingRecord = retrieveByGUIDDuringStore(record.guid);
+ if (record.deleted) {
+ if (existingRecord == null) {
+ // We're done. Don't bother with a callback. That can change later
+ // if we want it to.
+ trace("Incoming record " + record.guid + " is deleted, and no local version. Bye!");
+ return;
+ }
+
+ if (existingRecord.deleted) {
+ trace("Local record already deleted. Bye!");
+ return;
+ }
+
+ // Which one wins?
+ if (!remotelyModified) {
+ trace("Ignoring deleted record from the past.");
+ return;
+ }
+
+ boolean locallyModified = existingRecord.lastModified > lastLocalRetrieval;
+ if (!locallyModified) {
+ trace("Remote modified, local not. Deleting.");
+ storeRecordDeletion(record, existingRecord);
+ return;
+ }
+
+ trace("Both local and remote records have been modified.");
+ if (record.lastModified > existingRecord.lastModified) {
+ trace("Remote is newer, and deleted. Deleting local.");
+ storeRecordDeletion(record, existingRecord);
+ return;
+ }
+
+ trace("Remote is older, local is not deleted. Ignoring.");
+ return;
+ }
+ // End deletion logic.
+
+ // Now we're processing a non-deleted incoming record.
+ // Apply any changes we need in order to correctly find existing records.
+ fixupRecord(record);
+
+ if (existingRecord == null) {
+ trace("Looking up match for record " + record.guid);
+ existingRecord = findExistingRecord(record);
+ }
+
+ if (existingRecord == null) {
+ // The record is new.
+ trace("No match. Inserting.");
+ insert(record);
+ return;
+ }
+
+ // We found a local dupe.
+ trace("Incoming record " + record.guid + " dupes to local record " + existingRecord.guid);
+
+ // Populate more expensive fields prior to reconciling.
+ existingRecord = transformRecord(existingRecord);
+ Record toStore = reconcileRecords(record, existingRecord, lastRemoteRetrieval, lastLocalRetrieval);
+
+ if (toStore == null) {
+ Logger.debug(LOG_TAG, "Reconciling returned null. Not inserting a record.");
+ return;
+ }
+
+ // TODO: pass in timestamps?
+
+ // This section of code will only run if the incoming record is not
+ // marked as deleted, so we never want to just drop ours from the database:
+ // we need to upload it later.
+ // Allowing deleted items to propagate through `replace` allows normal
+ // logging and side-effects to occur, and is no more expensive than simply
+ // bumping the modified time.
+ Logger.debug(LOG_TAG, "Replacing existing " + existingRecord.guid +
+ (toStore.deleted ? " with deleted record " : " with record ") +
+ toStore.guid);
+ Record replaced = replace(toStore, existingRecord);
+
+ // Note that we don't track records here; deciding that is the job
+ // of reconcileRecords.
+ Logger.debug(LOG_TAG, "Calling delegate callback with guid " + replaced.guid +
+ "(" + replaced.androidID + ")");
+ delegate.onRecordStoreSucceeded(replaced.guid);
+ return;
+
+ } catch (MultipleRecordsForGuidException e) {
+ Logger.error(LOG_TAG, "Multiple records returned for given guid: " + record.guid);
+ delegate.onRecordStoreFailed(e, record.guid);
+ return;
+ } catch (NoGuidForIdException e) {
+ Logger.error(LOG_TAG, "Store failed for " + record.guid, e);
+ delegate.onRecordStoreFailed(e, record.guid);
+ return;
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Store failed for " + record.guid, e);
+ delegate.onRecordStoreFailed(e, record.guid);
+ return;
+ }
+ }
+ };
+ storeWorkQueue.execute(command);
+ }
+
+ /**
+ * Process a request for deletion of a record.
+ * Neither argument will ever be null.
+ *
+ * @param record the incoming record. This will be mostly blank, given that it's a deletion.
+ * @param existingRecord the existing record. Use this to decide how to process the deletion.
+ */
+ protected void storeRecordDeletion(final Record record, final Record existingRecord) {
+ // TODO: we ought to mark the record as deleted rather than purging it,
+ // in order to support syncing to multiple destinations. Bug 722607.
+ dbHelper.purgeGuid(record.guid);
+ delegate.onRecordStoreSucceeded(record.guid);
+ }
+
+ protected void insert(Record record) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
+ Record toStore = prepareRecord(record);
+ Uri recordURI = dbHelper.insert(toStore);
+ if (recordURI == null) {
+ throw new NullCursorException(new RuntimeException("Got null URI inserting record with guid " + record.guid));
+ }
+ toStore.androidID = ContentUris.parseId(recordURI);
+
+ updateBookkeeping(toStore);
+ trackRecord(toStore);
+ delegate.onRecordStoreSucceeded(toStore.guid);
+
+ Logger.debug(LOG_TAG, "Inserted record with guid " + toStore.guid + " as androidID " + toStore.androidID);
+ }
+
+ protected Record replace(Record newRecord, Record existingRecord) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
+ Record toStore = prepareRecord(newRecord);
+
+ // newRecord should already have suitable androidID and guid.
+ dbHelper.update(existingRecord.guid, toStore);
+ updateBookkeeping(toStore);
+ Logger.debug(LOG_TAG, "replace() returning record " + toStore.guid);
+ return toStore;
+ }
+
+ /**
+ * Retrieve a record from the store by GUID, without writing unnecessarily to the
+ * database.
+ *
+ * @throws NoGuidForIdException
+ * @throws NullCursorException
+ * @throws ParentNotFoundException
+ * @throws MultipleRecordsForGuidException
+ */
+ protected Record retrieveByGUIDDuringStore(String guid) throws
+ NoGuidForIdException,
+ NullCursorException,
+ ParentNotFoundException,
+ MultipleRecordsForGuidException {
+ Cursor cursor = dbHelper.fetch(new String[] { guid });
+ try {
+ if (!cursor.moveToFirst()) {
+ return null;
+ }
+
+ Record r = retrieveDuringStore(cursor);
+
+ cursor.moveToNext();
+ if (cursor.isAfterLast()) {
+ // Got one record!
+ return r; // Not transformed.
+ }
+
+ // More than one. Oh dear.
+ throw (new MultipleRecordsForGuidException(null));
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Attempt to find an equivalent record through some means other than GUID.
+ *
+ * @param record
+ * The record for which to search.
+ * @return
+ * An equivalent Record object, or null if none is found.
+ *
+ * @throws MultipleRecordsForGuidException
+ * @throws NoGuidForIdException
+ * @throws NullCursorException
+ * @throws ParentNotFoundException
+ */
+ protected Record findExistingRecord(Record record) throws MultipleRecordsForGuidException,
+ NoGuidForIdException, NullCursorException, ParentNotFoundException {
+
+ Logger.debug(LOG_TAG, "Finding existing record for incoming record with GUID " + record.guid);
+ String recordString = buildRecordString(record);
+ if (recordString == null) {
+ Logger.debug(LOG_TAG, "No record string for incoming record " + record.guid);
+ return null;
+ }
+
+ if (Logger.LOG_PERSONAL_INFORMATION) {
+ Logger.pii(LOG_TAG, "Searching with record string " + recordString);
+ } else {
+ Logger.debug(LOG_TAG, "Searching with record string.");
+ }
+ String guid = getGuidForString(recordString);
+ if (guid == null) {
+ Logger.debug(LOG_TAG, "Failed to find existing record for " + record.guid);
+ return null;
+ }
+
+ // Our map contained a match, but it could be a false positive. Since
+ // computed record string is supposed to be a unique key, we can easily
+ // verify our positive.
+ Logger.debug(LOG_TAG, "Found one. Checking stored record.");
+ Record stored = retrieveByGUIDDuringStore(guid);
+ String storedRecordString = buildRecordString(record);
+ if (recordString.equals(storedRecordString)) {
+ Logger.debug(LOG_TAG, "Existing record matches incoming record. Returning existing record.");
+ return stored;
+ }
+
+ // Oh no, we got a false positive! (This should be *very* rare --
+ // essentially, we got a hash collision.) Search the DB for this record
+ // explicitly by hand.
+ Logger.debug(LOG_TAG, "Existing record does not match incoming record. Trying to find record by record string.");
+ return findByRecordString(recordString);
+ }
+
+ protected String getGuidForString(String recordString) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
+ if (recordToGuid == null) {
+ createRecordToGuidMap();
+ }
+ return recordToGuid.get(recordString.hashCode());
+ }
+
+ protected void createRecordToGuidMap() throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
+ Logger.info(LOG_TAG, "BEGIN: creating record -> GUID map.");
+ recordToGuid = new SparseArray<String>();
+
+ // TODO: we should be able to do this entire thing with string concatenations within SQL.
+ // Also consider whether it's better to fetch and process every record in the DB into
+ // memory, or run a query per record to do the same thing.
+ Cursor cur = dbHelper.fetchAll();
+ try {
+ if (!cur.moveToFirst()) {
+ return;
+ }
+ while (!cur.isAfterLast()) {
+ Record record = retrieveDuringStore(cur);
+ if (record != null) {
+ final String recordString = buildRecordString(record);
+ if (recordString != null) {
+ recordToGuid.put(recordString.hashCode(), record.guid);
+ }
+ }
+ cur.moveToNext();
+ }
+ } finally {
+ cur.close();
+ }
+ Logger.info(LOG_TAG, "END: creating record -> GUID map.");
+ }
+
+ /**
+ * Search the local database for a record with the same "record string".
+ * <p>
+ * We expect to do this only in the unlikely event of a hash
+ * collision, so we iterate the database completely. Since we want
+ * to include information about the parents of bookmarks, it is
+ * difficult to do better purely using the
+ * <code>ContentProvider</code> interface.
+ *
+ * @param recordString
+ * the "record string" to search for; must be n
+ * @return a <code>Record</code> with the same "record string", or
+ * <code>null</code> if none is present.
+ * @throws ParentNotFoundException
+ * @throws NullCursorException
+ * @throws NoGuidForIdException
+ */
+ protected Record findByRecordString(String recordString) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
+ Cursor cur = dbHelper.fetchAll();
+ try {
+ if (!cur.moveToFirst()) {
+ return null;
+ }
+ while (!cur.isAfterLast()) {
+ Record record = retrieveDuringStore(cur);
+ if (record != null) {
+ final String storedRecordString = buildRecordString(record);
+ if (recordString.equals(storedRecordString)) {
+ return record;
+ }
+ }
+ cur.moveToNext();
+ }
+ return null;
+ } finally {
+ cur.close();
+ }
+ }
+
+ public void putRecordToGuidMap(String recordString, String guid) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
+ if (recordString == null) {
+ return;
+ }
+
+ if (recordToGuid == null) {
+ createRecordToGuidMap();
+ }
+ recordToGuid.put(recordString.hashCode(), guid);
+ }
+
+ protected abstract Record prepareRecord(Record record);
+
+ protected void updateBookkeeping(Record record) throws NoGuidForIdException,
+ NullCursorException,
+ ParentNotFoundException {
+ putRecordToGuidMap(buildRecordString(record), record.guid);
+ }
+
+ protected WipeRunnable getWipeRunnable(RepositorySessionWipeDelegate delegate) {
+ return new WipeRunnable(delegate);
+ }
+
+ @Override
+ public void wipe(RepositorySessionWipeDelegate delegate) {
+ Runnable command = getWipeRunnable(delegate);
+ storeWorkQueue.execute(command);
+ }
+
+ class WipeRunnable implements Runnable {
+ protected RepositorySessionWipeDelegate delegate;
+
+ public WipeRunnable(RepositorySessionWipeDelegate delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public void run() {
+ if (!isActive()) {
+ delegate.onWipeFailed(new InactiveSessionException(null));
+ return;
+ }
+ dbHelper.wipe();
+ delegate.onWipeSucceeded();
+ }
+ }
+
+ // For testing purposes.
+ public AndroidBrowserRepositoryDataAccessor getDBHelper() {
+ return dbHelper;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksDeletionManager.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksDeletionManager.java
new file mode 100644
index 000000000..d8d8756f7
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksDeletionManager.java
@@ -0,0 +1,239 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
+
+/**
+ * Queue up deletions. Process them at the end.
+ *
+ * Algorithm:
+ *
+ * * Collect GUIDs as we go. For convenience we partition these into
+ * folders and non-folders.
+ *
+ * * Non-folders can be deleted in batches as we go.
+ *
+ * * At the end of the sync:
+ * * Delete all that aren't folders.
+ * * Move the remaining children of any that are folders to an "Orphans" folder.
+ * - We do this even for children that are _marked_ as deleted -- we still want
+ * to upload them, and their parent is irrelevant.
+ * * Delete all the folders.
+ *
+ * * Any outstanding records -- the ones we moved to "Orphans" -- are true orphans.
+ * These should be reuploaded (because their parent has changed), as should their
+ * new parent (because its children array has changed).
+ * We achieve the former by moving them without tracking (but we don't make any
+ * special effort here -- warning! Lurking bug!).
+ * We achieve the latter by bumping its mtime. The caller should take care of untracking it.
+ *
+ * Note that we make no particular effort to handle repositioning or reparenting:
+ * batching deletes at the end should be handled seamlessly by existing code,
+ * because the deleted records could have arrived in a batch at the end regardless.
+ *
+ * Note that this class is not thread safe. This should be fine: call it only
+ * from within a store runnable.
+ *
+ */
+public class BookmarksDeletionManager {
+ private static final String LOG_TAG = "BookmarkDelete";
+
+ private final AndroidBrowserBookmarksDataAccessor dataAccessor;
+ private RepositorySessionStoreDelegate delegate;
+
+ private final int flushThreshold;
+
+ private final HashSet<String> folders = new HashSet<String>();
+ private final HashSet<String> nonFolders = new HashSet<String>();
+ private int nonFolderCount = 0;
+
+ // Records that we need to touch once we've deleted the non-folders.
+ private HashSet<String> nonFolderParents = new HashSet<String>();
+ private HashSet<String> folderParents = new HashSet<String>();
+
+ /**
+ * Create an instance to be used for tracking deletions in a bookmarks
+ * repository session.
+ *
+ * @param dataAccessor
+ * Used to effect database changes.
+ *
+ * @param flushThreshold
+ * When this many non-folder records have been stored for deletion,
+ * an incremental flush occurs.
+ */
+ public BookmarksDeletionManager(AndroidBrowserBookmarksDataAccessor dataAccessor, int flushThreshold) {
+ this.dataAccessor = dataAccessor;
+ this.flushThreshold = flushThreshold;
+ }
+
+ /**
+ * Set the delegate to use for callbacks.
+ * If not invoked, no callbacks will be submitted.
+ *
+ * @param delegate a delegate, which should already be a delayed delegate.
+ */
+ public void setDelegate(RepositorySessionStoreDelegate delegate) {
+ this.delegate = delegate;
+ }
+
+ public void deleteRecord(String guid, boolean isFolder, String parentGUID) {
+ if (guid == null) {
+ Logger.warn(LOG_TAG, "Cannot queue deletion of record with no GUID.");
+ return;
+ }
+ Logger.debug(LOG_TAG, "Queuing deletion of " + guid);
+
+ if (isFolder) {
+ folders.add(guid);
+ if (!folders.contains(parentGUID)) {
+ // We're not going to delete its parent; will need to bump it.
+ folderParents.add(parentGUID);
+ }
+
+ nonFolderParents.remove(guid);
+ folderParents.remove(guid);
+ return;
+ }
+
+ if (!folders.contains(parentGUID)) {
+ // We're not going to delete its parent; will need to bump it.
+ nonFolderParents.add(parentGUID);
+ }
+
+ if (nonFolders.add(guid)) {
+ if (++nonFolderCount >= flushThreshold) {
+ deleteNonFolders();
+ }
+ }
+ }
+
+ /**
+ * Flush deletions that can be easily taken care of right now.
+ */
+ public void incrementalFlush() {
+ // Yes, this means we only bump when we finish, not during an incremental flush.
+ deleteNonFolders();
+ }
+
+ /**
+ * Apply all pending deletions and reset state for the next batch of stores.
+ *
+ * @param orphanDestination the ID of the folder to which orphaned children
+ * should be moved.
+ *
+ * @throws NullCursorException
+ * @return a set of IDs to untrack. Will not be null.
+ */
+ public Set<String> flushAll(long orphanDestination, long now) throws NullCursorException {
+ Logger.debug(LOG_TAG, "Doing complete flush of deleted items. Moving orphans to " + orphanDestination);
+ deleteNonFolders();
+
+ // Find out which parents *won't* be deleted, and thus need to have their
+ // modified times bumped.
+ nonFolderParents.removeAll(folders);
+
+ Logger.debug(LOG_TAG, "Bumping modified times for " + nonFolderParents.size() +
+ " parents of deleted non-folders.");
+ dataAccessor.bumpModifiedByGUID(nonFolderParents, now);
+
+ if (folders.size() > 0) {
+ final String[] folderGUIDs = folders.toArray(new String[folders.size()]);
+ final String[] folderIDs = getIDs(folderGUIDs); // Throws if any don't exist.
+ int moved = dataAccessor.moveChildren(folderIDs, orphanDestination);
+ if (moved > 0) {
+ dataAccessor.bumpModified(orphanDestination, now);
+ }
+
+ // We've deleted or moved anything that might be under these folders.
+ // Just delete them.
+ final String folderWhere = RepoUtils.computeSQLInClause(folders.size(), BrowserContract.Bookmarks.GUID);
+ dataAccessor.delete(folderWhere, folderGUIDs);
+ invokeCallbacks(delegate, folderGUIDs);
+
+ folderParents.removeAll(folders);
+ Logger.debug(LOG_TAG, "Bumping modified times for " + folderParents.size() +
+ " parents of deleted folders.");
+ dataAccessor.bumpModifiedByGUID(folderParents, now);
+
+ // Clean up.
+ folders.clear();
+ }
+
+ HashSet<String> ret = nonFolderParents;
+ ret.addAll(folderParents);
+
+ nonFolderParents = new HashSet<String>();
+ folderParents = new HashSet<String>();
+ return ret;
+ }
+
+ private String[] getIDs(String[] guids) throws NullCursorException {
+ // Convert GUIDs to numeric IDs.
+ String[] ids = new String[guids.length];
+ Map<String, Long> guidsToIDs = dataAccessor.idsForGUIDs(guids);
+ for (int i = 0; i < guids.length; ++i) {
+ String guid = guids[i];
+ Long id = guidsToIDs.get(guid);
+ if (id == null) {
+ throw new IllegalArgumentException("Can't get ID for unknown record " + guid);
+ }
+ ids[i] = id.toString();
+ }
+ return ids;
+ }
+
+ /**
+ * Flush non-folder deletions. This can be called at any time.
+ */
+ private void deleteNonFolders() {
+ if (nonFolderCount == 0) {
+ Logger.debug(LOG_TAG, "No non-folders to delete.");
+ return;
+ }
+
+ Logger.debug(LOG_TAG, "Applying deletion of " + nonFolderCount + " non-folders.");
+ final String[] nonFolderGUIDs = nonFolders.toArray(new String[nonFolderCount]);
+ final String nonFolderWhere = RepoUtils.computeSQLInClause(nonFolderCount, BrowserContract.Bookmarks.GUID);
+ dataAccessor.delete(nonFolderWhere, nonFolderGUIDs);
+
+ invokeCallbacks(delegate, nonFolderGUIDs);
+
+ // Discard these.
+ // Note that we maintain folderParents and nonFolderParents; we need them later.
+ nonFolders.clear();
+ nonFolderCount = 0;
+ }
+
+ private void invokeCallbacks(RepositorySessionStoreDelegate delegate,
+ String[] nonFolderGUIDs) {
+ if (delegate == null) {
+ return;
+ }
+ Logger.trace(LOG_TAG, "Invoking store callback for " + nonFolderGUIDs.length + " GUIDs.");
+ for (String guid : nonFolderGUIDs) {
+ delegate.onRecordStoreSucceeded(guid);
+ }
+ }
+
+ /**
+ * Clear state in case of redundancy (e.g., wipe).
+ */
+ public void clear() {
+ nonFolders.clear();
+ nonFolderCount = 0;
+ folders.clear();
+ nonFolderParents.clear();
+ folderParents.clear();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksInsertionManager.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksInsertionManager.java
new file mode 100644
index 000000000..98670d39b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BookmarksInsertionManager.java
@@ -0,0 +1,298 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+
+/**
+ * Queue up insertions:
+ * <ul>
+ * <li>Folder inserts where the parent is known. Do these immediately, because
+ * they allow other records to be inserted. Requires bookkeeping updates. On
+ * insert, flush the next set.</li>
+ * <li>Regular inserts where the parent is known. These can happen whenever.
+ * Batch for speed.</li>
+ * <li>Records where the parent is not known. These can be flushed out when the
+ * parent is known, or entered as orphans. This can be a queue earlier in the
+ * process, so they don't get assigned to Unsorted. Feed into the main batch
+ * when the parent arrives.</li>
+ * </ul>
+ * <p>
+ * Deletions are always done at the end so that orphaning is minimized, and
+ * that's why we are batching folders and non-folders separately.
+ * <p>
+ * Updates are always applied as they arrive.
+ * <p>
+ * Note that this class is not thread safe. This should be fine: call it only
+ * from within a store runnable.
+ */
+public class BookmarksInsertionManager {
+ public static final String LOG_TAG = "BookmarkInsert";
+ public static boolean DEBUG = false;
+
+ protected final int flushThreshold;
+ protected final BookmarkInserter inserter;
+
+ /**
+ * Folders that have been successfully inserted.
+ */
+ private final Set<String> insertedFolders = new HashSet<String>();
+
+ /**
+ * Non-folders waiting for bulk insertion.
+ * <p>
+ * We write in insertion order to keep things easy to debug.
+ */
+ private final Set<BookmarkRecord> nonFoldersToWrite = new LinkedHashSet<BookmarkRecord>();
+
+ /**
+ * Map from parent folder GUID to child records (folders and non-folders)
+ * waiting to be enqueued after parent folder is inserted.
+ */
+ private final Map<String, Set<BookmarkRecord>> recordsWaitingForParent = new HashMap<String, Set<BookmarkRecord>>();
+
+ /**
+ * Create an instance to be used for tracking insertions in a bookmarks
+ * repository session.
+ *
+ * @param flushThreshold
+ * When this many non-folder records have been stored for insertion,
+ * an incremental flush occurs.
+ * @param insertedFolders
+ * The GUIDs of all the folders already inserted into the database.
+ * @param inserter
+ * The <code>BookmarkInsert</code> to use.
+ */
+ public BookmarksInsertionManager(int flushThreshold, Collection<String> insertedFolders, BookmarkInserter inserter) {
+ this.flushThreshold = flushThreshold;
+ this.insertedFolders.addAll(insertedFolders);
+ this.inserter = inserter;
+ }
+
+ protected void addRecordWithUnwrittenParent(BookmarkRecord record) {
+ Set<BookmarkRecord> destination = recordsWaitingForParent.get(record.parentID);
+ if (destination == null) {
+ destination = new LinkedHashSet<BookmarkRecord>();
+ recordsWaitingForParent.put(record.parentID, destination);
+ }
+ destination.add(record);
+ }
+
+ /**
+ * If <code>record</code> is a folder, insert it immediately; if it is a
+ * non-folder, enqueue it. Then do the same for any records waiting for this record.
+ *
+ * @param record
+ * the <code>BookmarkRecord</code> to enqueue.
+ */
+ protected void recursivelyEnqueueRecordAndChildren(BookmarkRecord record) {
+ if (record.isFolder()) {
+ if (!inserter.insertFolder(record)) {
+ Logger.warn(LOG_TAG, "Folder with known parent with guid " + record.parentID + " failed to insert!");
+ return;
+ }
+ Logger.debug(LOG_TAG, "Folder with known parent with guid " + record.parentID + " inserted; adding to inserted folders.");
+ insertedFolders.add(record.guid);
+ } else {
+ Logger.debug(LOG_TAG, "Non-folder has known parent with guid " + record.parentID + "; adding to insertion queue.");
+ nonFoldersToWrite.add(record);
+ }
+
+ // Now process record's children.
+ Set<BookmarkRecord> waiting = recordsWaitingForParent.remove(record.guid);
+ if (waiting == null) {
+ return;
+ }
+ for (BookmarkRecord waiter : waiting) {
+ recursivelyEnqueueRecordAndChildren(waiter);
+ }
+ }
+
+ /**
+ * Enqueue a folder.
+ *
+ * @param record
+ * the folder to enqueue.
+ */
+ protected void enqueueFolder(BookmarkRecord record) {
+ Logger.debug(LOG_TAG, "Inserting folder with guid " + record.guid);
+
+ if (!insertedFolders.contains(record.parentID)) {
+ Logger.debug(LOG_TAG, "Folder has unknown parent with guid " + record.parentID + "; keeping until we see the parent.");
+ addRecordWithUnwrittenParent(record);
+ return;
+ }
+
+ // Parent is known; add as much of the tree as this roots.
+ recursivelyEnqueueRecordAndChildren(record);
+ flushNonFoldersIfNecessary();
+ }
+
+ /**
+ * Enqueue a non-folder.
+ *
+ * @param record
+ * the non-folder to enqueue.
+ */
+ protected void enqueueNonFolder(BookmarkRecord record) {
+ Logger.debug(LOG_TAG, "Inserting non-folder with guid " + record.guid);
+
+ if (!insertedFolders.contains(record.parentID)) {
+ Logger.debug(LOG_TAG, "Non-folder has unknown parent with guid " + record.parentID + "; keeping until we see the parent.");
+ addRecordWithUnwrittenParent(record);
+ return;
+ }
+
+ // Parent is known; add to insertion queue and maybe write.
+ Logger.debug(LOG_TAG, "Non-folder has known parent with guid " + record.parentID + "; adding to insertion queue.");
+ nonFoldersToWrite.add(record);
+ flushNonFoldersIfNecessary();
+ }
+
+ /**
+ * Enqueue a bookmark record for eventual insertion.
+ *
+ * @param record
+ * the <code>BookmarkRecord</code> to enqueue.
+ */
+ public void enqueueRecord(BookmarkRecord record) {
+ if (record.isFolder()) {
+ enqueueFolder(record);
+ } else {
+ enqueueNonFolder(record);
+ }
+ if (DEBUG) {
+ dumpState();
+ }
+ }
+
+ /**
+ * Flush non-folders; empties the insertion queue entirely.
+ */
+ protected void flushNonFolders() {
+ inserter.bulkInsertNonFolders(nonFoldersToWrite); // All errors are handled in bulkInsertNonFolders.
+ nonFoldersToWrite.clear();
+ }
+
+ /**
+ * Flush non-folder insertions if there are many of them; empties the
+ * insertion queue entirely.
+ */
+ protected void flushNonFoldersIfNecessary() {
+ int num = nonFoldersToWrite.size();
+ if (num < flushThreshold) {
+ Logger.debug(LOG_TAG, "Incremental flush called with " + num + " < " + flushThreshold + " non-folders; not flushing.");
+ return;
+ }
+ Logger.debug(LOG_TAG, "Incremental flush called with " + num + " non-folders; flushing.");
+ flushNonFolders();
+ }
+
+ /**
+ * Insert all remaining folders followed by all remaining non-folders,
+ * regardless of whether parent records have been successfully inserted.
+ */
+ public void finishUp() {
+ // Iterate through all waiting records, writing the folders and collecting
+ // the non-folders for bulk insertion.
+ int numFolders = 0;
+ int numNonFolders = 0;
+ for (Set<BookmarkRecord> records : recordsWaitingForParent.values()) {
+ for (BookmarkRecord record : records) {
+ if (!record.isFolder()) {
+ numNonFolders += 1;
+ nonFoldersToWrite.add(record);
+ continue;
+ }
+
+ numFolders += 1;
+ if (!inserter.insertFolder(record)) {
+ Logger.warn(LOG_TAG, "Folder with known parent with guid " + record.parentID + " failed to insert!");
+ continue;
+ }
+
+ Logger.debug(LOG_TAG, "Folder with known parent with guid " + record.parentID + " inserted; adding to inserted folders.");
+ insertedFolders.add(record.guid);
+ }
+ }
+ recordsWaitingForParent.clear();
+ flushNonFolders();
+
+ Logger.debug(LOG_TAG, "finishUp inserted " +
+ numFolders + " folders without known parents and " +
+ numNonFolders + " non-folders without known parents.");
+ if (DEBUG) {
+ dumpState();
+ }
+ }
+
+ public void clear() {
+ this.insertedFolders.clear();
+ this.nonFoldersToWrite.clear();
+ this.recordsWaitingForParent.clear();
+ }
+
+ // For debugging.
+ public boolean isClear() {
+ return nonFoldersToWrite.isEmpty() && recordsWaitingForParent.isEmpty();
+ }
+
+ // For debugging.
+ public void dumpState() {
+ ArrayList<String> readies = new ArrayList<String>();
+ for (BookmarkRecord record : nonFoldersToWrite) {
+ readies.add(record.guid);
+ }
+ String ready = Utils.toCommaSeparatedString(new ArrayList<String>(readies));
+
+ ArrayList<String> waits = new ArrayList<String>();
+ for (Set<BookmarkRecord> recs : recordsWaitingForParent.values()) {
+ for (BookmarkRecord rec : recs) {
+ waits.add(rec.guid);
+ }
+ }
+ String waiting = Utils.toCommaSeparatedString(waits);
+ String known = Utils.toCommaSeparatedString(insertedFolders);
+
+ Logger.debug(LOG_TAG, "Q=(" + ready + "), W = (" + waiting + "), P=(" + known + ")");
+ }
+
+ public interface BookmarkInserter {
+ /**
+ * Insert a single folder.
+ * <p>
+ * All exceptions should be caught and all delegate callbacks invoked here.
+ *
+ * @param record
+ * the record to insert.
+ * @return
+ * <code>true</code> if the folder was inserted; <code>false</code> otherwise.
+ */
+ public boolean insertFolder(BookmarkRecord record);
+
+ /**
+ * Insert many non-folders. Each non-folder's parent was already present in
+ * the database before this <code>BookmarkInsertionsManager</code> was
+ * created, or had <code>insertFolder</code> called with it as argument (and
+ * possibly was not inserted).
+ * <p>
+ * All exceptions should be caught and all delegate callbacks invoked here.
+ *
+ * @param records
+ * the records to insert.
+ */
+ public void bulkInsertNonFolders(Collection<BookmarkRecord> records);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpers.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpers.java
new file mode 100644
index 000000000..e83aea087
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpers.java
@@ -0,0 +1,154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sync.setup.Constants;
+
+import android.net.Uri;
+
+public class BrowserContractHelpers extends BrowserContract {
+
+ protected static Uri withSyncAndDeletedAndProfile(Uri u) {
+ return u.buildUpon()
+ .appendQueryParameter(PARAM_PROFILE, Constants.DEFAULT_PROFILE)
+ .appendQueryParameter(PARAM_IS_SYNC, "true")
+ .appendQueryParameter(PARAM_SHOW_DELETED, "true")
+ .build();
+ }
+ protected static Uri withSyncAndProfile(Uri u) {
+ return u.buildUpon()
+ .appendQueryParameter(PARAM_PROFILE, Constants.DEFAULT_PROFILE)
+ .appendQueryParameter(PARAM_IS_SYNC, "true")
+ .build();
+ }
+
+ public static final Uri BOOKMARKS_CONTENT_URI = withSyncAndDeletedAndProfile(Bookmarks.CONTENT_URI);
+ public static final Uri BOOKMARKS_PARENTS_CONTENT_URI = withSyncAndDeletedAndProfile(Bookmarks.PARENTS_CONTENT_URI);
+ public static final Uri BOOKMARKS_POSITIONS_CONTENT_URI = withSyncAndDeletedAndProfile(Bookmarks.POSITIONS_CONTENT_URI);
+ public static final Uri HISTORY_CONTENT_URI = withSyncAndDeletedAndProfile(History.CONTENT_URI);
+ public static final Uri VISITS_CONTENT_URI = withSyncAndDeletedAndProfile(Visits.CONTENT_URI);
+ public static final Uri SCHEMA_CONTENT_URI = withSyncAndDeletedAndProfile(Schema.CONTENT_URI);
+ public static final Uri PASSWORDS_CONTENT_URI = withSyncAndDeletedAndProfile(Passwords.CONTENT_URI);
+ public static final Uri DELETED_PASSWORDS_CONTENT_URI = withSyncAndDeletedAndProfile(DeletedPasswords.CONTENT_URI);
+ public static final Uri FORM_HISTORY_CONTENT_URI = withSyncAndProfile(FormHistory.CONTENT_URI);
+ public static final Uri DELETED_FORM_HISTORY_CONTENT_URI = withSyncAndProfile(DeletedFormHistory.CONTENT_URI);
+ public static final Uri TABS_CONTENT_URI = withSyncAndProfile(Tabs.CONTENT_URI);
+ public static final Uri CLIENTS_CONTENT_URI = withSyncAndProfile(Clients.CONTENT_URI);
+ public static final Uri LOGINS_CONTENT_URI = withSyncAndProfile(Logins.CONTENT_URI);
+
+ public static final String[] PasswordColumns = new String[] {
+ Passwords.ID,
+ Passwords.HOSTNAME,
+ Passwords.HTTP_REALM,
+ Passwords.FORM_SUBMIT_URL,
+ Passwords.USERNAME_FIELD,
+ Passwords.PASSWORD_FIELD,
+ Passwords.ENCRYPTED_USERNAME,
+ Passwords.ENCRYPTED_PASSWORD,
+ Passwords.ENC_TYPE,
+ Passwords.TIME_CREATED,
+ Passwords.TIME_LAST_USED,
+ Passwords.TIME_PASSWORD_CHANGED,
+ Passwords.TIMES_USED,
+ Passwords.GUID
+ };
+
+ public static final String[] HistoryColumns = new String[] {
+ CommonColumns._ID,
+ SyncColumns.GUID,
+ SyncColumns.DATE_CREATED,
+ SyncColumns.DATE_MODIFIED,
+ SyncColumns.IS_DELETED,
+ History.TITLE,
+ History.URL,
+ History.DATE_LAST_VISITED,
+ History.VISITS
+ };
+
+ public static final String[] BookmarkColumns = new String[] {
+ CommonColumns._ID,
+ SyncColumns.GUID,
+ SyncColumns.DATE_CREATED,
+ SyncColumns.DATE_MODIFIED,
+ SyncColumns.IS_DELETED,
+ Bookmarks.TITLE,
+ Bookmarks.URL,
+ Bookmarks.TYPE,
+ Bookmarks.PARENT,
+ Bookmarks.POSITION,
+ Bookmarks.TAGS,
+ Bookmarks.DESCRIPTION,
+ Bookmarks.KEYWORD
+ };
+
+ public static final String[] FormHistoryColumns = new String[] {
+ FormHistory.ID,
+ FormHistory.GUID,
+ FormHistory.FIELD_NAME,
+ FormHistory.VALUE,
+ FormHistory.TIMES_USED,
+ FormHistory.FIRST_USED,
+ FormHistory.LAST_USED
+ };
+
+ public static final String[] DeletedColumns = new String[] {
+ BrowserContract.DeletedColumns.ID,
+ BrowserContract.DeletedColumns.GUID,
+ BrowserContract.DeletedColumns.TIME_DELETED
+ };
+
+ // Mapping from Sync types to Fennec types.
+ public static final String[] BOOKMARK_TYPE_CODE_TO_STRING = {
+ // Observe omissions: "microsummary", "item".
+ "folder", "bookmark", "separator", "livemark", "query"
+ };
+ private static final int MAX_BOOKMARK_TYPE_CODE = BOOKMARK_TYPE_CODE_TO_STRING.length - 1;
+ public static final Map<String, Integer> BOOKMARK_TYPE_STRING_TO_CODE;
+ static {
+ HashMap<String, Integer> t = new HashMap<String, Integer>();
+ t.put("folder", Bookmarks.TYPE_FOLDER);
+ t.put("bookmark", Bookmarks.TYPE_BOOKMARK);
+ t.put("separator", Bookmarks.TYPE_SEPARATOR);
+ t.put("livemark", Bookmarks.TYPE_LIVEMARK);
+ t.put("query", Bookmarks.TYPE_QUERY);
+ BOOKMARK_TYPE_STRING_TO_CODE = Collections.unmodifiableMap(t);
+ }
+
+ /**
+ * Convert a database bookmark type code into the Sync string equivalent.
+ *
+ * @param code one of the <code>Bookmarks.TYPE_*</code> enumerations.
+ * @return the string equivalent, or null if not found.
+ */
+ public static String typeStringForCode(int code) {
+ if (0 <= code && code <= MAX_BOOKMARK_TYPE_CODE) {
+ return BOOKMARK_TYPE_CODE_TO_STRING[code];
+ }
+ return null;
+ }
+
+ /**
+ * Convert a Sync type string into a Fennec type code.
+ *
+ * @param type a type string, such as "livemark".
+ * @return the type code, or -1 if not found.
+ */
+ public static int typeCodeForString(String type) {
+ Integer found = BOOKMARK_TYPE_STRING_TO_CODE.get(type);
+ if (found == null) {
+ return -1;
+ }
+ return found;
+ }
+
+ public static boolean isSupportedType(String type) {
+ return BOOKMARK_TYPE_STRING_TO_CODE.containsKey(type);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/CachedSQLiteOpenHelper.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/CachedSQLiteOpenHelper.java
new file mode 100644
index 000000000..5c17f9b85
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/CachedSQLiteOpenHelper.java
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteDatabase.CursorFactory;
+import android.database.sqlite.SQLiteOpenHelper;
+
+public abstract class CachedSQLiteOpenHelper extends SQLiteOpenHelper {
+
+ public CachedSQLiteOpenHelper(Context context, String name, CursorFactory factory,
+ int version) {
+ super(context, name, factory, version);
+ }
+
+ // Cache these so we don't have to track them across cursors. Call `close`
+ // when you're done.
+ private SQLiteDatabase readableDatabase;
+ private SQLiteDatabase writableDatabase;
+
+ synchronized protected SQLiteDatabase getCachedReadableDatabase() {
+ if (readableDatabase == null) {
+ if (writableDatabase == null) {
+ readableDatabase = this.getReadableDatabase();
+ return readableDatabase;
+ } else {
+ return writableDatabase;
+ }
+ } else {
+ return readableDatabase;
+ }
+ }
+
+ synchronized protected SQLiteDatabase getCachedWritableDatabase() {
+ if (writableDatabase == null) {
+ writableDatabase = this.getWritableDatabase();
+ }
+ return writableDatabase;
+ }
+
+ @Override
+ synchronized public void close() {
+ if (readableDatabase != null) {
+ readableDatabase.close();
+ readableDatabase = null;
+ }
+ if (writableDatabase != null) {
+ writableDatabase.close();
+ writableDatabase = null;
+ }
+ super.close();
+ }
+
+ // Used for testing.
+ public boolean isClosed() {
+ return readableDatabase == null &&
+ writableDatabase == null;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabase.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabase.java
new file mode 100644
index 000000000..4962a20c6
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabase.java
@@ -0,0 +1,252 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+
+public class ClientsDatabase extends CachedSQLiteOpenHelper {
+
+ public static final String LOG_TAG = "ClientsDatabase";
+
+ // Database Specifications.
+ protected static final String DB_NAME = "clients_database";
+ protected static final int SCHEMA_VERSION = 3;
+
+ // Clients Table.
+ public static final String TBL_CLIENTS = "clients";
+ public static final String COL_ACCOUNT_GUID = "guid";
+ public static final String COL_PROFILE = "profile";
+ public static final String COL_NAME = "name";
+ public static final String COL_TYPE = "device_type";
+
+ // Optional fields.
+ public static final String COL_FORMFACTOR = "formfactor";
+ public static final String COL_OS = "os";
+ public static final String COL_APPLICATION = "application";
+ public static final String COL_APP_PACKAGE = "appPackage";
+ public static final String COL_DEVICE = "device";
+
+ public static final String[] TBL_CLIENTS_COLUMNS = new String[] { COL_ACCOUNT_GUID, COL_PROFILE, COL_NAME, COL_TYPE,
+ COL_FORMFACTOR, COL_OS, COL_APPLICATION, COL_APP_PACKAGE, COL_DEVICE };
+ public static final String TBL_CLIENTS_KEY = COL_ACCOUNT_GUID + " = ? AND " +
+ COL_PROFILE + " = ?";
+
+ // Commands Table.
+ public static final String TBL_COMMANDS = "commands";
+ public static final String COL_COMMAND = "command";
+ public static final String COL_ARGS = "args";
+
+ public static final String[] TBL_COMMANDS_COLUMNS = new String[] { COL_ACCOUNT_GUID, COL_COMMAND, COL_ARGS };
+ public static final String TBL_COMMANDS_KEY = COL_ACCOUNT_GUID + " = ? AND " +
+ COL_COMMAND + " = ? AND " +
+ COL_ARGS + " = ?";
+ public static final String TBL_COMMANDS_GUID_QUERY = COL_ACCOUNT_GUID + " = ? ";
+
+ private final RepoUtils.QueryHelper queryHelper;
+
+ public ClientsDatabase(Context context) {
+ super(context, DB_NAME, null, SCHEMA_VERSION);
+ this.queryHelper = new RepoUtils.QueryHelper(context, null, LOG_TAG);
+ Logger.debug(LOG_TAG, "ClientsDatabase instantiated.");
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ Logger.debug(LOG_TAG, "ClientsDatabase.onCreate().");
+ createClientsTable(db);
+ createCommandsTable(db);
+ }
+
+ public static void createClientsTable(SQLiteDatabase db) {
+ Logger.debug(LOG_TAG, "ClientsDatabase.createClientsTable().");
+ String createClientsTableSql = "CREATE TABLE " + TBL_CLIENTS + " ("
+ + COL_ACCOUNT_GUID + " TEXT, "
+ + COL_PROFILE + " TEXT, "
+ + COL_NAME + " TEXT, "
+ + COL_TYPE + " TEXT, "
+ + COL_FORMFACTOR + " TEXT, "
+ + COL_OS + " TEXT, "
+ + COL_APPLICATION + " TEXT, "
+ + COL_APP_PACKAGE + " TEXT, "
+ + COL_DEVICE + " TEXT, "
+ + "PRIMARY KEY (" + COL_ACCOUNT_GUID + ", " + COL_PROFILE + "))";
+ db.execSQL(createClientsTableSql);
+ }
+
+ public static void createCommandsTable(SQLiteDatabase db) {
+ Logger.debug(LOG_TAG, "ClientsDatabase.createCommandsTable().");
+ String createCommandsTableSql = "CREATE TABLE " + TBL_COMMANDS + " ("
+ + COL_ACCOUNT_GUID + " TEXT, "
+ + COL_COMMAND + " TEXT, "
+ + COL_ARGS + " TEXT, "
+ + "PRIMARY KEY (" + COL_ACCOUNT_GUID + ", " + COL_COMMAND + ", " + COL_ARGS + "), "
+ + "FOREIGN KEY (" + COL_ACCOUNT_GUID + ") REFERENCES " + TBL_CLIENTS + " (" + COL_ACCOUNT_GUID + "))";
+ db.execSQL(createCommandsTableSql);
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ Logger.debug(LOG_TAG, "ClientsDatabase.onUpgrade(" + oldVersion + ", " + newVersion + ").");
+ if (oldVersion < 2) {
+ // For now we'll just drop and recreate the tables.
+ db.execSQL("DROP TABLE IF EXISTS " + TBL_CLIENTS);
+ db.execSQL("DROP TABLE IF EXISTS " + TBL_COMMANDS);
+ onCreate(db);
+ return;
+ }
+
+ if (newVersion >= 3) {
+ // Add the optional columns to clients.
+ db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_FORMFACTOR + " TEXT");
+ db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_OS + " TEXT");
+ db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_APPLICATION + " TEXT");
+ db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_APP_PACKAGE + " TEXT");
+ db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_DEVICE + " TEXT");
+ }
+ }
+
+ public void wipeDB() {
+ SQLiteDatabase db = this.getCachedWritableDatabase();
+ onUpgrade(db, 0, SCHEMA_VERSION);
+ }
+
+ public void wipeClientsTable() {
+ SQLiteDatabase db = this.getCachedWritableDatabase();
+ db.execSQL("DELETE FROM " + TBL_CLIENTS);
+ }
+
+ public void wipeCommandsTable() {
+ SQLiteDatabase db = this.getCachedWritableDatabase();
+ db.execSQL("DELETE FROM " + TBL_COMMANDS);
+ }
+
+ // If a record with given GUID exists, we'll update it,
+ // otherwise we'll insert it.
+ public void store(String profileId, ClientRecord record) {
+ SQLiteDatabase db = this.getCachedWritableDatabase();
+
+ ContentValues cv = new ContentValues();
+ cv.put(COL_ACCOUNT_GUID, record.guid);
+ cv.put(COL_PROFILE, profileId);
+ cv.put(COL_NAME, record.name);
+ cv.put(COL_TYPE, record.type);
+
+ if (record.formfactor != null) {
+ cv.put(COL_FORMFACTOR, record.formfactor);
+ }
+
+ if (record.os != null) {
+ cv.put(COL_OS, record.os);
+ }
+
+ if (record.application != null) {
+ cv.put(COL_APPLICATION, record.application);
+ }
+
+ if (record.appPackage != null) {
+ cv.put(COL_APP_PACKAGE, record.appPackage);
+ }
+
+ if (record.device != null) {
+ cv.put(COL_DEVICE, record.device);
+ }
+
+ String[] args = new String[] { record.guid, profileId };
+ int rowsUpdated = db.update(TBL_CLIENTS, cv, TBL_CLIENTS_KEY, args);
+
+ if (rowsUpdated >= 1) {
+ Logger.debug(LOG_TAG, "Replaced client record for row with accountGUID " + record.guid);
+ } else {
+ long rowId = db.insert(TBL_CLIENTS, null, cv);
+ Logger.debug(LOG_TAG, "Inserted client record into row: " + rowId);
+ }
+ }
+
+ /**
+ * Store a command in the commands database if it doesn't already exist.
+ *
+ * @param accountGUID
+ * @param command - The command type
+ * @param args - A JSON string of args
+ * @throws NullCursorException
+ */
+ public void store(String accountGUID, String command, String args) throws NullCursorException {
+ if (Logger.LOG_PERSONAL_INFORMATION) {
+ Logger.pii(LOG_TAG, "Storing command " + command + " with args " + args);
+ } else {
+ Logger.trace(LOG_TAG, "Storing command " + command + ".");
+ }
+ SQLiteDatabase db = this.getCachedWritableDatabase();
+
+ ContentValues cv = new ContentValues();
+ cv.put(COL_ACCOUNT_GUID, accountGUID);
+ cv.put(COL_COMMAND, command);
+ if (args == null) {
+ cv.put(COL_ARGS, "[]");
+ } else {
+ cv.put(COL_ARGS, args);
+ }
+
+ Cursor cur = this.fetchSpecificCommand(accountGUID, command, args);
+ try {
+ if (cur.moveToFirst()) {
+ Logger.debug(LOG_TAG, "Command already exists in database.");
+ return;
+ }
+ } finally {
+ cur.close();
+ }
+
+ long rowId = db.insert(TBL_COMMANDS, null, cv);
+ Logger.debug(LOG_TAG, "Inserted command into row: " + rowId);
+ }
+
+ public Cursor fetchClientsCursor(String accountGUID, String profileId) throws NullCursorException {
+ String[] args = new String[] { accountGUID, profileId };
+ SQLiteDatabase db = this.getCachedReadableDatabase();
+
+ return queryHelper.safeQuery(db, ".fetchClientsCursor", TBL_CLIENTS, TBL_CLIENTS_COLUMNS, TBL_CLIENTS_KEY, args);
+ }
+
+ public Cursor fetchSpecificCommand(String accountGUID, String command, String commandArgs) throws NullCursorException {
+ String[] args = new String[] { accountGUID, command, commandArgs };
+ SQLiteDatabase db = this.getCachedReadableDatabase();
+
+ return queryHelper.safeQuery(db, ".fetchSpecificCommand", TBL_COMMANDS, TBL_COMMANDS_COLUMNS, TBL_COMMANDS_KEY, args);
+ }
+
+ public Cursor fetchCommandsForClient(String accountGUID) throws NullCursorException {
+ String[] args = new String[] { accountGUID };
+ SQLiteDatabase db = this.getCachedReadableDatabase();
+
+ return queryHelper.safeQuery(db, ".fetchCommandsForClient", TBL_COMMANDS, TBL_COMMANDS_COLUMNS, TBL_COMMANDS_GUID_QUERY, args);
+ }
+
+ public Cursor fetchAllClients() throws NullCursorException {
+ SQLiteDatabase db = this.getCachedReadableDatabase();
+
+ return queryHelper.safeQuery(db, ".fetchAllClients", TBL_CLIENTS, TBL_CLIENTS_COLUMNS, null, null);
+ }
+
+ public Cursor fetchAllCommands() throws NullCursorException {
+ SQLiteDatabase db = this.getCachedReadableDatabase();
+
+ return queryHelper.safeQuery(db, ".fetchAllCommands", TBL_COMMANDS, TBL_COMMANDS_COLUMNS, null, null);
+ }
+
+ public void deleteClient(String accountGUID, String profileId) {
+ String[] args = new String[] { accountGUID, profileId };
+
+ SQLiteDatabase db = this.getCachedWritableDatabase();
+ db.delete(TBL_CLIENTS, TBL_CLIENTS_KEY, args);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabaseAccessor.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabaseAccessor.java
new file mode 100644
index 000000000..4af84ceaf
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabaseAccessor.java
@@ -0,0 +1,178 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+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 org.json.simple.JSONArray;
+
+import org.mozilla.gecko.sync.CommandProcessor.Command;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.mozilla.gecko.sync.setup.Constants;
+
+import android.content.Context;
+import android.database.Cursor;
+
+public class ClientsDatabaseAccessor {
+
+ public static final String LOG_TAG = "ClientsDatabaseAccessor";
+
+ private ClientsDatabase db;
+
+ // Need this so we can properly stub out the class for testing.
+ public ClientsDatabaseAccessor() {}
+
+ public ClientsDatabaseAccessor(Context context) {
+ db = new ClientsDatabase(context);
+ }
+
+ public void store(ClientRecord record) {
+ db.store(getProfileId(), record);
+ }
+
+ public void store(Collection<ClientRecord> records) {
+ for (ClientRecord record : records) {
+ this.store(record);
+ }
+ }
+
+ public void store(String accountGUID, Command command) throws NullCursorException {
+ db.store(accountGUID, command.commandType, command.args.toJSONString());
+ }
+
+ public ClientRecord fetchClient(String accountGUID) throws NullCursorException {
+ final Cursor cur = db.fetchClientsCursor(accountGUID, getProfileId());
+ try {
+ if (!cur.moveToFirst()) {
+ return null;
+ }
+ return recordFromCursor(cur);
+ } finally {
+ cur.close();
+ }
+ }
+
+ public Map<String, ClientRecord> fetchAllClients() throws NullCursorException {
+ final HashMap<String, ClientRecord> map = new HashMap<String, ClientRecord>();
+ final Cursor cur = db.fetchAllClients();
+ try {
+ if (!cur.moveToFirst()) {
+ return Collections.unmodifiableMap(map);
+ }
+
+ while (!cur.isAfterLast()) {
+ ClientRecord clientRecord = recordFromCursor(cur);
+ map.put(clientRecord.guid, clientRecord);
+ cur.moveToNext();
+ }
+ return Collections.unmodifiableMap(map);
+ } finally {
+ cur.close();
+ }
+ }
+
+ public List<Command> fetchAllCommands() throws NullCursorException {
+ final List<Command> commands = new ArrayList<Command>();
+ final Cursor cur = db.fetchAllCommands();
+ try {
+ if (!cur.moveToFirst()) {
+ return Collections.unmodifiableList(commands);
+ }
+
+ while (!cur.isAfterLast()) {
+ Command command = commandFromCursor(cur);
+ commands.add(command);
+ cur.moveToNext();
+ }
+ return Collections.unmodifiableList(commands);
+ } finally {
+ cur.close();
+ }
+ }
+
+ public List<Command> fetchCommandsForClient(String accountGUID) throws NullCursorException {
+ final List<Command> commands = new ArrayList<Command>();
+ final Cursor cur = db.fetchCommandsForClient(accountGUID);
+ try {
+ if (!cur.moveToFirst()) {
+ return Collections.unmodifiableList(commands);
+ }
+
+ while(!cur.isAfterLast()) {
+ Command command = commandFromCursor(cur);
+ commands.add(command);
+ cur.moveToNext();
+ }
+ return Collections.unmodifiableList(commands);
+ } finally {
+ cur.close();
+ }
+ }
+
+ protected static ClientRecord recordFromCursor(Cursor cur) {
+ final String accountGUID = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_ACCOUNT_GUID);
+ final String clientName = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_NAME);
+ final String clientType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_TYPE);
+
+ final ClientRecord record = new ClientRecord(accountGUID);
+ record.name = clientName;
+ record.type = clientType;
+
+ // Optional fields. These will either be null or strings.
+ record.formfactor = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_FORMFACTOR);
+ record.os = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_OS);
+ record.device = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_DEVICE);
+ record.appPackage = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_APP_PACKAGE);
+ record.application = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_APPLICATION);
+
+ return record;
+ }
+
+ protected static Command commandFromCursor(Cursor cur) {
+ String commandType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_COMMAND);
+ JSONArray commandArgs = RepoUtils.getJSONArrayFromCursor(cur, ClientsDatabase.COL_ARGS);
+ return new Command(commandType, commandArgs);
+ }
+
+ public int clientsCount() {
+ try {
+ final Cursor cur = db.fetchAllClients();
+ try {
+ return cur.getCount();
+ } finally {
+ cur.close();
+ }
+ } catch (NullCursorException e) {
+ return 0;
+ }
+
+ }
+
+ private String getProfileId() {
+ return Constants.DEFAULT_PROFILE;
+ }
+
+ public void wipeDB() {
+ db.wipeDB();
+ }
+
+ public void wipeClientsTable() {
+ db.wipeClientsTable();
+ }
+
+ public void wipeCommandsTable() {
+ db.wipeCommandsTable();
+ }
+
+ public void close() {
+ db.close();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FennecTabsRepository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FennecTabsRepository.java
new file mode 100644
index 000000000..720d856eb
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FennecTabsRepository.java
@@ -0,0 +1,383 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.ArrayList;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.db.Tab;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.Clients;
+import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.NoContentProviderException;
+import org.mozilla.gecko.sync.repositories.NoStoreDelegateException;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+import org.mozilla.gecko.sync.repositories.domain.TabsRecord;
+
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+
+public class FennecTabsRepository extends Repository {
+ private static final String LOG_TAG = "FennecTabsRepository";
+
+ protected final ClientsDataDelegate clientsDataDelegate;
+
+ public FennecTabsRepository(ClientsDataDelegate clientsDataDelegate) {
+ this.clientsDataDelegate = clientsDataDelegate;
+ }
+
+ /**
+ * Note that -- unlike most repositories -- this will only fetch Fennec's tabs,
+ * and only store tabs from other clients.
+ *
+ * It will never retrieve tabs from other clients, or store tabs for Fennec,
+ * unless you use {@link #fetch(String[], RepositorySessionFetchRecordsDelegate)}
+ * and specify an explicit GUID.
+ */
+ public class FennecTabsRepositorySession extends RepositorySession {
+ protected static final String LOG_TAG = "FennecTabsSession";
+
+ private final ContentProviderClient tabsProvider;
+ private final ContentProviderClient clientsProvider;
+
+ protected final RepoUtils.QueryHelper tabsHelper;
+
+ protected final ClientsDatabaseAccessor clientsDatabase;
+
+ protected ContentProviderClient getContentProvider(final Context context, final Uri uri) throws NoContentProviderException {
+ ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(uri);
+ if (client == null) {
+ throw new NoContentProviderException(uri);
+ }
+ return client;
+ }
+
+ protected void releaseProviders() {
+ try {
+ clientsProvider.release();
+ } catch (Exception e) {}
+ try {
+ tabsProvider.release();
+ } catch (Exception e) {}
+ clientsDatabase.close();
+ }
+
+ public FennecTabsRepositorySession(Repository repository, Context context) throws NoContentProviderException {
+ super(repository);
+ clientsProvider = getContentProvider(context, BrowserContractHelpers.CLIENTS_CONTENT_URI);
+ try {
+ tabsProvider = getContentProvider(context, BrowserContractHelpers.TABS_CONTENT_URI);
+ } catch (NoContentProviderException e) {
+ clientsProvider.release();
+ throw e;
+ } catch (Exception e) {
+ clientsProvider.release();
+ // Oh, Java.
+ throw new RuntimeException(e);
+ }
+
+ tabsHelper = new RepoUtils.QueryHelper(context, BrowserContractHelpers.TABS_CONTENT_URI, LOG_TAG);
+ clientsDatabase = new ClientsDatabaseAccessor(context);
+ }
+
+ @Override
+ public void abort() {
+ releaseProviders();
+ super.abort();
+ }
+
+ @Override
+ public void finish(final RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
+ releaseProviders();
+ super.finish(delegate);
+ }
+
+ // Default parameters for local data: local client has null GUID. Override
+ // these to test against non-live data.
+ protected String localClientSelection() {
+ return BrowserContract.Tabs.CLIENT_GUID + " IS NULL";
+ }
+
+ protected String[] localClientSelectionArgs() {
+ return null;
+ }
+
+ @Override
+ public void guidsSince(final long timestamp,
+ final RepositorySessionGuidsSinceDelegate delegate) {
+ // Bug 783692: Now that Bug 730039 has landed, we could implement this,
+ // but it's not a priority since it's not used (yet).
+ Logger.warn(LOG_TAG, "Not returning anything from guidsSince.");
+ delegateQueue.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.onGuidsSinceSucceeded(new String[] {});
+ }
+ });
+ }
+
+ @Override
+ public void fetchSince(final long timestamp,
+ final RepositorySessionFetchRecordsDelegate delegate) {
+ if (tabsProvider == null) {
+ throw new IllegalArgumentException("tabsProvider was null.");
+ }
+ if (tabsHelper == null) {
+ throw new IllegalArgumentException("tabsHelper was null.");
+ }
+
+ final String positionAscending = BrowserContract.Tabs.POSITION + " ASC";
+
+ final String localClientSelection = localClientSelection();
+ final String[] localClientSelectionArgs = localClientSelectionArgs();
+
+ final Runnable command = new Runnable() {
+ @Override
+ public void run() {
+ // We fetch all local tabs (since the record must contain them all)
+ // but only process the record if the timestamp is sufficiently
+ // recent, or if the client data has been modified.
+ try {
+ final Cursor cursor = tabsHelper.safeQuery(tabsProvider, ".fetchSince()", null,
+ localClientSelection, localClientSelectionArgs, positionAscending);
+ try {
+ final String localClientGuid = clientsDataDelegate.getAccountGUID();
+ final String localClientName = clientsDataDelegate.getClientName();
+ final TabsRecord tabsRecord = FennecTabsRepository.tabsRecordFromCursor(cursor, localClientGuid, localClientName);
+
+ if (tabsRecord.lastModified >= timestamp ||
+ clientsDataDelegate.getLastModifiedTimestamp() >= timestamp) {
+ delegate.onFetchedRecord(tabsRecord);
+ }
+ } finally {
+ cursor.close();
+ }
+ } catch (Exception e) {
+ delegate.onFetchFailed(e, null);
+ return;
+ }
+ delegate.onFetchCompleted(now());
+ }
+ };
+
+ delegateQueue.execute(command);
+ }
+
+ @Override
+ public void fetch(final String[] guids,
+ final RepositorySessionFetchRecordsDelegate delegate) {
+ // Bug 783692: Now that Bug 730039 has landed, we could implement this,
+ // but it's not a priority since it's not used (yet).
+ Logger.warn(LOG_TAG, "Not returning anything from fetch");
+ delegateQueue.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.onFetchCompleted(now());
+ }
+ });
+ }
+
+ @Override
+ public void fetchAll(final RepositorySessionFetchRecordsDelegate delegate) {
+ fetchSince(0, delegate);
+ }
+
+ private static final String TABS_CLIENT_GUID_IS = BrowserContract.Tabs.CLIENT_GUID + " = ?";
+ private static final String CLIENT_GUID_IS = BrowserContract.Clients.GUID + " = ?";
+
+ @Override
+ public void store(final Record record) throws NoStoreDelegateException {
+ if (delegate == null) {
+ Logger.warn(LOG_TAG, "No store delegate.");
+ throw new NoStoreDelegateException();
+ }
+ if (record == null) {
+ Logger.error(LOG_TAG, "Record sent to store was null");
+ throw new IllegalArgumentException("Null record passed to FennecTabsRepositorySession.store().");
+ }
+ if (!(record instanceof TabsRecord)) {
+ Logger.error(LOG_TAG, "Can't store anything but a TabsRecord");
+ throw new IllegalArgumentException("Non-TabsRecord passed to FennecTabsRepositorySession.store().");
+ }
+ final TabsRecord tabsRecord = (TabsRecord) record;
+
+ Runnable command = new Runnable() {
+ @Override
+ public void run() {
+ Logger.debug(LOG_TAG, "Storing tabs for client " + tabsRecord.guid);
+ if (!isActive()) {
+ delegate.onRecordStoreFailed(new InactiveSessionException(null), record.guid);
+ return;
+ }
+ if (tabsRecord.guid == null) {
+ delegate.onRecordStoreFailed(new RuntimeException("Can't store record with null GUID."), record.guid);
+ return;
+ }
+
+ try {
+ // This is nice and easy: we *always* store.
+ final String[] selectionArgs = new String[] { tabsRecord.guid };
+ if (tabsRecord.deleted) {
+ try {
+ Logger.debug(LOG_TAG, "Clearing entry for client " + tabsRecord.guid);
+ clientsProvider.delete(BrowserContractHelpers.CLIENTS_CONTENT_URI,
+ CLIENT_GUID_IS,
+ selectionArgs);
+ delegate.onRecordStoreSucceeded(record.guid);
+ } catch (Exception e) {
+ delegate.onRecordStoreFailed(e, record.guid);
+ }
+ return;
+ }
+
+ // If it exists, update the client record; otherwise insert.
+ final ContentValues clientsCV = tabsRecord.getClientsContentValues();
+
+ final ClientRecord clientRecord = clientsDatabase.fetchClient(tabsRecord.guid);
+ if (null != clientRecord) {
+ // Null is an acceptable device type.
+ clientsCV.put(Clients.DEVICE_TYPE, clientRecord.type);
+ }
+
+ Logger.debug(LOG_TAG, "Updating clients provider.");
+ final int updated = clientsProvider.update(BrowserContractHelpers.CLIENTS_CONTENT_URI,
+ clientsCV,
+ CLIENT_GUID_IS,
+ selectionArgs);
+ if (0 == updated) {
+ clientsProvider.insert(BrowserContractHelpers.CLIENTS_CONTENT_URI, clientsCV);
+ }
+
+ // Now insert tabs.
+ final ContentValues[] tabsArray = tabsRecord.getTabsContentValues();
+ Logger.debug(LOG_TAG, "Inserting " + tabsArray.length + " tabs for client " + tabsRecord.guid);
+
+ tabsProvider.delete(BrowserContractHelpers.TABS_CONTENT_URI, TABS_CLIENT_GUID_IS, selectionArgs);
+ final int inserted = tabsProvider.bulkInsert(BrowserContractHelpers.TABS_CONTENT_URI, tabsArray);
+ Logger.trace(LOG_TAG, "Inserted: " + inserted);
+
+ delegate.onRecordStoreSucceeded(record.guid);
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Error storing tabs.", e);
+ delegate.onRecordStoreFailed(e, record.guid);
+ }
+ }
+ };
+
+ storeWorkQueue.execute(command);
+ }
+
+ @Override
+ public void wipe(RepositorySessionWipeDelegate delegate) {
+ try {
+ tabsProvider.delete(BrowserContractHelpers.TABS_CONTENT_URI, null, null);
+ clientsProvider.delete(BrowserContractHelpers.CLIENTS_CONTENT_URI, null, null);
+ } catch (RemoteException e) {
+ Logger.warn(LOG_TAG, "Got RemoteException in wipe.", e);
+ delegate.onWipeFailed(e);
+ return;
+ }
+ delegate.onWipeSucceeded();
+ }
+ }
+
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ try {
+ final FennecTabsRepositorySession session = new FennecTabsRepositorySession(this, context);
+ delegate.onSessionCreated(session);
+ } catch (Exception e) {
+ delegate.onSessionCreateFailed(e);
+ }
+ }
+
+ /**
+ * Extract a <code>TabsRecord</code> from a cursor.
+ * <p>
+ * Caller is responsible for creating and closing cursor. Each row of the
+ * cursor should be an individual tab record.
+ * <p>
+ * The extracted tabs record has the given client GUID and client name.
+ *
+ * @param cursor
+ * to inspect.
+ * @param clientGuid
+ * returned tabs record will have this client GUID.
+ * @param clientName
+ * returned tabs record will have this client name.
+ * @return <code>TabsRecord</code> instance.
+ */
+ public static TabsRecord tabsRecordFromCursor(final Cursor cursor, final String clientGuid, final String clientName) {
+ final String collection = "tabs";
+ final TabsRecord record = new TabsRecord(clientGuid, collection, 0, false);
+ record.tabs = new ArrayList<Tab>();
+ record.clientName = clientName;
+
+ record.androidID = -1;
+ record.deleted = false;
+
+ record.lastModified = 0;
+
+ int position = cursor.getPosition();
+ try {
+ cursor.moveToFirst();
+ while (!cursor.isAfterLast()) {
+ final Tab tab = Tab.fromCursor(cursor);
+ record.tabs.add(tab);
+
+ if (tab.lastUsed > record.lastModified) {
+ record.lastModified = tab.lastUsed;
+ }
+
+ cursor.moveToNext();
+ }
+ } finally {
+ cursor.moveToPosition(position);
+ }
+
+ return record;
+ }
+
+ /**
+ * Deletes all non-local clients and their associated remote tabs.
+ */
+ public static void deleteNonLocalClientsAndTabs(Context context) {
+ final String nonLocalClientSelection = BrowserContract.Clients.GUID + " IS NOT NULL";
+
+ ContentProviderClient clientsProvider = context.getContentResolver()
+ .acquireContentProviderClient(BrowserContractHelpers.CLIENTS_CONTENT_URI);
+ if (clientsProvider == null) {
+ Logger.warn(LOG_TAG, "Unable to create clientsProvider!");
+ return;
+ }
+
+ try {
+ Logger.info(LOG_TAG, "Clearing all non-local clients and their associated remote tabs for default profile.");
+ clientsProvider.delete(BrowserContractHelpers.CLIENTS_CONTENT_URI, nonLocalClientSelection, null);
+ } catch (RemoteException e) {
+ Logger.warn(LOG_TAG, "Error while deleting", e);
+ } finally {
+ try {
+ clientsProvider.release();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception releasing clientsProvider!", e);
+ }
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FormHistoryRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FormHistoryRepositorySession.java
new file mode 100644
index 000000000..9beafa712
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/FormHistoryRepositorySession.java
@@ -0,0 +1,723 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.DeletedFormHistory;
+import org.mozilla.gecko.db.BrowserContract.FormHistory;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.NoContentProviderException;
+import org.mozilla.gecko.sync.repositories.NoStoreDelegateException;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.RecordFilter;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.StoreTrackingRepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
+import org.mozilla.gecko.sync.repositories.domain.FormHistoryRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+
+public class FormHistoryRepositorySession extends
+ StoreTrackingRepositorySession {
+ public static final String LOG_TAG = "FormHistoryRepoSess";
+
+ /**
+ * Number of records to insert in one batch.
+ */
+ public static final int INSERT_ITEM_THRESHOLD = 200;
+
+ private static final Uri FORM_HISTORY_CONTENT_URI = BrowserContractHelpers.FORM_HISTORY_CONTENT_URI;
+ private static final Uri DELETED_FORM_HISTORY_CONTENT_URI = BrowserContractHelpers.DELETED_FORM_HISTORY_CONTENT_URI;
+
+ public static class FormHistoryRepository extends Repository {
+
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ try {
+ final FormHistoryRepositorySession session = new FormHistoryRepositorySession(this, context);
+ delegate.onSessionCreated(session);
+ } catch (Exception e) {
+ delegate.onSessionCreateFailed(e);
+ }
+ }
+ }
+
+ protected final ContentProviderClient formsProvider;
+ protected final RepoUtils.QueryHelper regularHelper;
+ protected final RepoUtils.QueryHelper deletedHelper;
+
+ /**
+ * Acquire the content provider client.
+ * <p>
+ * The caller is responsible for releasing the client.
+ *
+ * @param context The application context.
+ * @return The <code>ContentProviderClient</code>.
+ * @throws NoContentProviderException
+ */
+ public static ContentProviderClient acquireContentProvider(final Context context)
+ throws NoContentProviderException {
+ Uri uri = BrowserContract.FORM_HISTORY_AUTHORITY_URI;
+ ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(uri);
+ if (client == null) {
+ throw new NoContentProviderException(uri);
+ }
+ return client;
+ }
+
+ protected void releaseProviders() {
+ try {
+ if (formsProvider != null) {
+ formsProvider.release();
+ }
+ } catch (Exception e) {
+ }
+ }
+
+ // Only used for testing.
+ public ContentProviderClient getFormsProvider() {
+ return formsProvider;
+ }
+
+ public FormHistoryRepositorySession(Repository repository, Context context)
+ throws NoContentProviderException {
+ super(repository);
+ formsProvider = acquireContentProvider(context);
+ regularHelper = new RepoUtils.QueryHelper(context, BrowserContractHelpers.FORM_HISTORY_CONTENT_URI, LOG_TAG);
+ deletedHelper = new RepoUtils.QueryHelper(context, BrowserContractHelpers.DELETED_FORM_HISTORY_CONTENT_URI, LOG_TAG);
+ }
+
+ @Override
+ public void abort() {
+ releaseProviders();
+ super.abort();
+ }
+
+ @Override
+ public void finish(final RepositorySessionFinishDelegate delegate)
+ throws InactiveSessionException {
+ releaseProviders();
+ super.finish(delegate);
+ }
+
+ protected static final String[] GUID_COLUMNS = new String[] { FormHistory.GUID };
+
+ @Override
+ public void guidsSince(final long timestamp, final RepositorySessionGuidsSinceDelegate delegate) {
+ Runnable command = new Runnable() {
+ @Override
+ public void run() {
+ if (!isActive()) {
+ delegate.onGuidsSinceFailed(new InactiveSessionException(null));
+ return;
+ }
+
+ ArrayList<String> guids = new ArrayList<String>();
+
+ final long sharedEnd = now();
+ Cursor cur = null;
+ try {
+ cur = regularHelper.safeQuery(formsProvider, "", GUID_COLUMNS, regularBetween(timestamp, sharedEnd), null, null);
+ cur.moveToFirst();
+ while (!cur.isAfterLast()) {
+ guids.add(cur.getString(0));
+ cur.moveToNext();
+ }
+ } catch (RemoteException | NullCursorException e) {
+ delegate.onGuidsSinceFailed(e);
+ return;
+ } finally {
+ if (cur != null) {
+ cur.close();
+ }
+ }
+
+ try {
+ cur = deletedHelper.safeQuery(formsProvider, "", GUID_COLUMNS, deletedBetween(timestamp, sharedEnd), null, null);
+ cur.moveToFirst();
+ while (!cur.isAfterLast()) {
+ guids.add(cur.getString(0));
+ cur.moveToNext();
+ }
+ } catch (RemoteException | NullCursorException e) {
+ delegate.onGuidsSinceFailed(e);
+ return;
+ } finally {
+ if (cur != null) {
+ cur.close();
+ }
+ }
+
+ String guidsArray[] = guids.toArray(new String[guids.size()]);
+ delegate.onGuidsSinceSucceeded(guidsArray);
+ }
+ };
+ delegateQueue.execute(command);
+ }
+
+ protected static FormHistoryRecord retrieveDuringFetch(final Cursor cursor) {
+ // A simple and efficient way to distinguish two tables.
+ if (cursor.getColumnCount() == BrowserContractHelpers.FormHistoryColumns.length) {
+ return formHistoryRecordFromCursor(cursor);
+ } else {
+ return deletedFormHistoryRecordFromCursor(cursor);
+ }
+ }
+
+ protected static FormHistoryRecord formHistoryRecordFromCursor(final Cursor cursor) {
+ String guid = RepoUtils.getStringFromCursor(cursor, FormHistory.GUID);
+ String collection = "forms";
+ FormHistoryRecord record = new FormHistoryRecord(guid, collection, 0, false);
+
+ record.fieldName = RepoUtils.getStringFromCursor(cursor, FormHistory.FIELD_NAME);
+ record.fieldValue = RepoUtils.getStringFromCursor(cursor, FormHistory.VALUE);
+ record.androidID = RepoUtils.getLongFromCursor(cursor, FormHistory.ID);
+ record.lastModified = RepoUtils.getLongFromCursor(cursor, FormHistory.FIRST_USED) / 1000; // Convert microseconds to milliseconds.
+ record.deleted = false;
+
+ record.log(LOG_TAG);
+ return record;
+ }
+
+ protected static FormHistoryRecord deletedFormHistoryRecordFromCursor(final Cursor cursor) {
+ String guid = RepoUtils.getStringFromCursor(cursor, DeletedFormHistory.GUID);
+ String collection = "forms";
+ FormHistoryRecord record = new FormHistoryRecord(guid, collection, 0, false);
+
+ record.guid = RepoUtils.getStringFromCursor(cursor, DeletedFormHistory.GUID);
+ record.androidID = RepoUtils.getLongFromCursor(cursor, DeletedFormHistory.ID);
+ record.lastModified = RepoUtils.getLongFromCursor(cursor, DeletedFormHistory.TIME_DELETED);
+ record.deleted = true;
+
+ record.log(LOG_TAG);
+ return record;
+ }
+
+ protected static void fetchFromCursor(final Cursor cursor, final RecordFilter filter, final RepositorySessionFetchRecordsDelegate delegate)
+ throws NullCursorException {
+ Logger.debug(LOG_TAG, "Fetch from cursor");
+ if (cursor == null) {
+ throw new NullCursorException(null);
+ }
+ try {
+ if (!cursor.moveToFirst()) {
+ return;
+ }
+ while (!cursor.isAfterLast()) {
+ Record r = retrieveDuringFetch(cursor);
+ if (r != null) {
+ if (filter == null || !filter.excludeRecord(r)) {
+ Logger.trace(LOG_TAG, "Processing record " + r.guid);
+ delegate.onFetchedRecord(r);
+ } else {
+ Logger.debug(LOG_TAG, "Skipping filtered record " + r.guid);
+ }
+ }
+ cursor.moveToNext();
+ }
+ } finally {
+ Logger.trace(LOG_TAG, "Closing cursor after fetch.");
+ cursor.close();
+ }
+ }
+
+ protected void fetchHelper(final RepositorySessionFetchRecordsDelegate delegate, final long end, final List<Callable<Cursor>> cursorCallables) {
+ if (this.storeTracker == null) {
+ throw new IllegalStateException("Store tracker not yet initialized!");
+ }
+
+ final RecordFilter filter = this.storeTracker.getFilter();
+
+ Runnable command = new Runnable() {
+ @Override
+ public void run() {
+ if (!isActive()) {
+ delegate.onFetchFailed(new InactiveSessionException(null), null);
+ return;
+ }
+
+ for (Callable<Cursor> cursorCallable : cursorCallables) {
+ Cursor cursor = null;
+ try {
+ cursor = cursorCallable.call();
+ fetchFromCursor(cursor, filter, delegate); // Closes cursor.
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Exception during fetchHelper", e);
+ delegate.onFetchFailed(e, null);
+ return;
+ }
+ }
+
+ delegate.onFetchCompleted(end);
+ }
+ };
+
+ delegateQueue.execute(command);
+ }
+
+ protected static String regularBetween(long start, long end) {
+ return FormHistory.FIRST_USED + " >= " + Long.toString(1000 * start) + " AND " +
+ FormHistory.FIRST_USED + " <= " + Long.toString(1000 * end); // Microseconds.
+ }
+
+ protected static String deletedBetween(long start, long end) {
+ return DeletedFormHistory.TIME_DELETED + " >= " + Long.toString(start) + " AND " +
+ DeletedFormHistory.TIME_DELETED + " <= " + Long.toString(end); // Milliseconds.
+ }
+
+ @Override
+ public void fetchSince(final long timestamp, final RepositorySessionFetchRecordsDelegate delegate) {
+ Logger.trace(LOG_TAG, "Running fetchSince(" + timestamp + ").");
+
+ /*
+ * We need to be careful about the timestamp we complete the fetch with. If
+ * the first cursor Callable takes a year, then the second could return
+ * records long after the first was kicked off. To protect against this, we
+ * set an end point and bound our search.
+ */
+ final long sharedEnd = now();
+
+ Callable<Cursor> regularCallable = new Callable<Cursor>() {
+ @Override
+ public Cursor call() throws Exception {
+ return regularHelper.safeQuery(formsProvider, ".fetchSince(regular)", null, regularBetween(timestamp, sharedEnd), null, null);
+ }
+ };
+
+ Callable<Cursor> deletedCallable = new Callable<Cursor>() {
+ @Override
+ public Cursor call() throws Exception {
+ return deletedHelper.safeQuery(formsProvider, ".fetchSince(deleted)", null, deletedBetween(timestamp, sharedEnd), null, null);
+ }
+ };
+
+ @SuppressWarnings("unchecked")
+ List<Callable<Cursor>> callableCursors = Arrays.asList(regularCallable, deletedCallable);
+
+ fetchHelper(delegate, sharedEnd, callableCursors);
+ }
+
+ @Override
+ public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) {
+ Logger.trace(LOG_TAG, "Running fetchAll.");
+ fetchSince(0, delegate);
+ }
+
+ @Override
+ public void fetch(final String[] guids, final RepositorySessionFetchRecordsDelegate delegate) {
+ Logger.trace(LOG_TAG, "Running fetch.");
+
+ final long sharedEnd = now();
+ final String where = RepoUtils.computeSQLInClause(guids.length, FormHistory.GUID);
+
+ Callable<Cursor> regularCallable = new Callable<Cursor>() {
+ @Override
+ public Cursor call() throws Exception {
+ String regularWhere = where + " AND " + FormHistory.FIRST_USED + " <= " + Long.toString(1000 * sharedEnd); // Microseconds.
+ return regularHelper.safeQuery(formsProvider, ".fetch(regular)", null, regularWhere, guids, null);
+ }
+ };
+
+ Callable<Cursor> deletedCallable = new Callable<Cursor>() {
+ @Override
+ public Cursor call() throws Exception {
+ String deletedWhere = where + " AND " + DeletedFormHistory.TIME_DELETED + " <= " + Long.toString(sharedEnd); // Milliseconds.
+ return deletedHelper.safeQuery(formsProvider, ".fetch(deleted)", null, deletedWhere, guids, null);
+ }
+ };
+
+ @SuppressWarnings("unchecked")
+ List<Callable<Cursor>> callableCursors = Arrays.asList(regularCallable, deletedCallable);
+
+ fetchHelper(delegate, sharedEnd, callableCursors);
+ }
+
+ protected static final String GUID_IS = FormHistory.GUID + " = ?";
+
+ protected Record findExistingRecordByGuid(String guid)
+ throws RemoteException, NullCursorException {
+ Cursor cursor = null;
+ try {
+ cursor = regularHelper.safeQuery(formsProvider, ".findExistingRecordByGuid(regular)",
+ null, GUID_IS, new String[] { guid }, null);
+ if (cursor.moveToFirst()) {
+ return formHistoryRecordFromCursor(cursor);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ try {
+ cursor = deletedHelper.safeQuery(formsProvider, ".findExistingRecordByGuid(deleted)",
+ null, GUID_IS, new String[] { guid }, null);
+ if (cursor.moveToFirst()) {
+ return deletedFormHistoryRecordFromCursor(cursor);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ return null;
+ }
+
+ protected Record findExistingRecordByPayload(Record rawRecord)
+ throws RemoteException, NullCursorException {
+ if (!rawRecord.deleted) {
+ FormHistoryRecord record = (FormHistoryRecord) rawRecord;
+ Cursor cursor = null;
+ try {
+ String where = FormHistory.FIELD_NAME + " = ? AND " + FormHistory.VALUE + " = ?";
+ cursor = regularHelper.safeQuery(formsProvider, ".findExistingRecordByPayload",
+ null, where, new String[] { record.fieldName, record.fieldValue }, null);
+ if (cursor.moveToFirst()) {
+ return formHistoryRecordFromCursor(cursor);
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Called when a record with locally known GUID has been reported deleted by
+ * the server.
+ * <p>
+ * We purge the record's GUID from the regular and deleted tables.
+ *
+ * @param existingRecord
+ * The local <code>Record</code> to replace.
+ * @throws RemoteException
+ */
+ protected void deleteExistingRecord(Record existingRecord) throws RemoteException {
+ if (existingRecord.deleted) {
+ formsProvider.delete(DELETED_FORM_HISTORY_CONTENT_URI, GUID_IS, new String[] { existingRecord.guid });
+ return;
+ }
+ formsProvider.delete(FORM_HISTORY_CONTENT_URI, GUID_IS, new String[] { existingRecord.guid });
+ }
+
+ protected static ContentValues contentValuesForRegularRecord(Record rawRecord) {
+ if (rawRecord.deleted) {
+ throw new IllegalArgumentException("Deleted record passed to insertNewRegularRecord.");
+ }
+
+ FormHistoryRecord record = (FormHistoryRecord) rawRecord;
+ ContentValues cv = new ContentValues();
+ cv.put(FormHistory.GUID, record.guid);
+ cv.put(FormHistory.FIELD_NAME, record.fieldName);
+ cv.put(FormHistory.VALUE, record.fieldValue);
+ cv.put(FormHistory.FIRST_USED, 1000 * record.lastModified); // Microseconds.
+ return cv;
+ }
+
+ protected final Object recordsBufferMonitor = new Object();
+ protected ArrayList<ContentValues> recordsBuffer = new ArrayList<ContentValues>();
+
+ protected void enqueueRegularRecord(Record record) {
+ synchronized (recordsBufferMonitor) {
+ if (recordsBuffer.size() >= INSERT_ITEM_THRESHOLD) {
+ // Insert the existing contents, then enqueue.
+ try {
+ flushInsertQueue();
+ } catch (Exception e) {
+ delegate.onRecordStoreFailed(e, record.guid);
+ return;
+ }
+ }
+ // Store the ContentValues, rather than the record.
+ recordsBuffer.add(contentValuesForRegularRecord(record));
+ }
+ }
+
+ // Should always be called from storeWorkQueue.
+ protected void flushInsertQueue() throws RemoteException {
+ synchronized (recordsBufferMonitor) {
+ if (recordsBuffer.size() > 0) {
+ final ContentValues[] outgoing = recordsBuffer.toArray(new ContentValues[recordsBuffer.size()]);
+ recordsBuffer = new ArrayList<ContentValues>();
+
+ if (outgoing == null || outgoing.length == 0) {
+ Logger.debug(LOG_TAG, "No form history items to insert; returning immediately.");
+ return;
+ }
+
+ long before = System.currentTimeMillis();
+ formsProvider.bulkInsert(FORM_HISTORY_CONTENT_URI, outgoing);
+ long after = System.currentTimeMillis();
+ Logger.debug(LOG_TAG, "Inserted " + outgoing.length + " form history items in (" + (after - before) + " milliseconds).");
+ }
+ }
+ }
+
+ @Override
+ public void storeDone() {
+ Runnable command = new Runnable() {
+ @Override
+ public void run() {
+ Logger.debug(LOG_TAG, "Checking for residual form history items to insert.");
+ try {
+ synchronized (recordsBufferMonitor) {
+ flushInsertQueue();
+ }
+ storeDone(now());
+ } catch (Exception e) {
+ // XXX TODO
+ delegate.onRecordStoreFailed(e, null);
+ }
+ }
+ };
+ storeWorkQueue.execute(command);
+ }
+
+ /**
+ * Called when a regular record with locally unknown GUID has been fetched
+ * from the server.
+ * <p>
+ * Since the record is regular, we insert it into the regular table.
+ *
+ * @param record The regular <code>Record</code> from the server.
+ * @throws RemoteException
+ */
+ protected void insertNewRegularRecord(Record record)
+ throws RemoteException {
+ enqueueRegularRecord(record);
+ }
+
+ /**
+ * Called when a regular record with has been fetched from the server and
+ * should replace an existing record.
+ * <p>
+ * We delete the existing record entirely, and then insert the new record into
+ * the regular table.
+ *
+ * @param toStore
+ * The regular <code>Record</code> from the server.
+ * @param existingRecord
+ * The local <code>Record</code> to replace.
+ * @throws RemoteException
+ */
+ protected void replaceExistingRecordWithRegularRecord(Record toStore, Record existingRecord)
+ throws RemoteException {
+ if (existingRecord.deleted) {
+ // Need two database operations -- purge from deleted table, insert into regular table.
+ deleteExistingRecord(existingRecord);
+ insertNewRegularRecord(toStore);
+ return;
+ }
+
+ final ContentValues cv = contentValuesForRegularRecord(toStore);
+ int updated = formsProvider.update(FORM_HISTORY_CONTENT_URI, cv, GUID_IS, new String[] { existingRecord.guid });
+ if (updated != 1) {
+ Logger.warn(LOG_TAG, "Expected to update 1 record with guid " + existingRecord.guid + " but updated " + updated + " records.");
+ }
+ }
+
+ @Override
+ public void store(Record rawRecord) throws NoStoreDelegateException {
+ if (delegate == null) {
+ Logger.warn(LOG_TAG, "No store delegate.");
+ throw new NoStoreDelegateException();
+ }
+ if (rawRecord == null) {
+ Logger.error(LOG_TAG, "Record sent to store was null");
+ throw new IllegalArgumentException("Null record passed to FormHistoryRepositorySession.store().");
+ }
+ if (!(rawRecord instanceof FormHistoryRecord)) {
+ Logger.error(LOG_TAG, "Can't store anything but a FormHistoryRecord");
+ throw new IllegalArgumentException("Non-FormHistoryRecord passed to FormHistoryRepositorySession.store().");
+ }
+ final FormHistoryRecord record = (FormHistoryRecord) rawRecord;
+
+ Runnable command = new Runnable() {
+ @Override
+ public void run() {
+ if (!isActive()) {
+ Logger.warn(LOG_TAG, "FormHistoryRepositorySession is inactive. Store failing.");
+ delegate.onRecordStoreFailed(new InactiveSessionException(null), record.guid);
+ return;
+ }
+
+ // TODO: lift these into the session.
+ // Temporary: this matches prior syncing semantics, in which only
+ // the relationship between the local and remote record is considered.
+ // In the future we'll track these two timestamps and use them to
+ // determine which records have changed, and thus process incoming
+ // records more efficiently.
+ long lastLocalRetrieval = 0; // lastSyncTimestamp?
+ long lastRemoteRetrieval = 0; // TODO: adjust for clock skew.
+ boolean remotelyModified = record.lastModified > lastRemoteRetrieval;
+
+ Record existingRecord;
+ try {
+ // GUID matching only: deleted records don't have a payload with which to search.
+ existingRecord = findExistingRecordByGuid(record.guid);
+ if (record.deleted) {
+ if (existingRecord == null) {
+ // We're done. Don't bother with a callback. That can change later
+ // if we want it to.
+ Logger.trace(LOG_TAG, "Incoming record " + record.guid + " is deleted, and no local version. Bye!");
+ return;
+ }
+
+ if (existingRecord.deleted) {
+ Logger.trace(LOG_TAG, "Local record already deleted. Purging local.");
+ deleteExistingRecord(existingRecord);
+ return;
+ }
+
+ // Which one wins?
+ if (!remotelyModified) {
+ Logger.trace(LOG_TAG, "Ignoring deleted record from the past.");
+ return;
+ }
+
+ boolean locallyModified = existingRecord.lastModified > lastLocalRetrieval;
+ if (!locallyModified) {
+ Logger.trace(LOG_TAG, "Remote modified, local not. Deleting.");
+ deleteExistingRecord(existingRecord);
+ trackRecord(record);
+ delegate.onRecordStoreSucceeded(record.guid);
+ return;
+ }
+
+ Logger.trace(LOG_TAG, "Both local and remote records have been modified.");
+ if (record.lastModified > existingRecord.lastModified) {
+ Logger.trace(LOG_TAG, "Remote is newer, and deleted. Purging local.");
+ deleteExistingRecord(existingRecord);
+ trackRecord(record);
+ delegate.onRecordStoreSucceeded(record.guid);
+ return;
+ }
+
+ Logger.trace(LOG_TAG, "Remote is older, local is not deleted. Ignoring.");
+ if (!locallyModified) {
+ Logger.warn(LOG_TAG, "Inconsistency: old remote record is deleted, but local record not modified!");
+ // Ensure that this is tracked for upload.
+ }
+ return;
+ }
+ // End deletion logic.
+
+ // Now we're processing a non-deleted incoming record.
+ if (existingRecord == null) {
+ Logger.trace(LOG_TAG, "Looking up match for record " + record.guid);
+ existingRecord = findExistingRecordByPayload(record);
+ }
+
+ if (existingRecord == null) {
+ // The record is new.
+ Logger.trace(LOG_TAG, "No match. Inserting.");
+ insertNewRegularRecord(record);
+ trackRecord(record);
+ delegate.onRecordStoreSucceeded(record.guid);
+ return;
+ }
+
+ // We found a local duplicate.
+ Logger.trace(LOG_TAG, "Incoming record " + record.guid + " dupes to local record " + existingRecord.guid);
+
+ if (!RepoUtils.stringsEqual(record.guid, existingRecord.guid)) {
+ // We found a local record that does NOT have the same GUID -- keep the server's version.
+ Logger.trace(LOG_TAG, "Remote guid different from local guid. Storing to keep remote guid.");
+ replaceExistingRecordWithRegularRecord(record, existingRecord);
+ trackRecord(record);
+ delegate.onRecordStoreSucceeded(record.guid);
+ return;
+ }
+
+ // We found a local record that does have the same GUID -- check modification times.
+ boolean locallyModified = existingRecord.lastModified > lastLocalRetrieval;
+ if (!locallyModified) {
+ Logger.trace(LOG_TAG, "Remote modified, local not. Storing.");
+ replaceExistingRecordWithRegularRecord(record, existingRecord);
+ trackRecord(record);
+ delegate.onRecordStoreSucceeded(record.guid);
+ return;
+ }
+
+ Logger.trace(LOG_TAG, "Both local and remote records have been modified.");
+ if (record.lastModified > existingRecord.lastModified) {
+ Logger.trace(LOG_TAG, "Remote is newer, and not deleted. Storing.");
+ replaceExistingRecordWithRegularRecord(record, existingRecord);
+ trackRecord(record);
+ delegate.onRecordStoreSucceeded(record.guid);
+ return;
+ }
+
+ Logger.trace(LOG_TAG, "Remote is older, local is not deleted. Ignoring.");
+ if (!locallyModified) {
+ Logger.warn(LOG_TAG, "Inconsistency: old remote record is not deleted, but local record not modified!");
+ }
+ return;
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Store failed for " + record.guid, e);
+ delegate.onRecordStoreFailed(e, record.guid);
+ return;
+ }
+ }
+ };
+
+ storeWorkQueue.execute(command);
+ }
+
+ /**
+ * Purge all data from the underlying databases.
+ */
+ public static void purgeDatabases(ContentProviderClient formsProvider)
+ throws RemoteException {
+ formsProvider.delete(FORM_HISTORY_CONTENT_URI, null, null);
+ formsProvider.delete(DELETED_FORM_HISTORY_CONTENT_URI, null, null);
+ }
+
+ @Override
+ public void wipe(final RepositorySessionWipeDelegate delegate) {
+ Runnable command = new Runnable() {
+ @Override
+ public void run() {
+ if (!isActive()) {
+ delegate.onWipeFailed(new InactiveSessionException(null));
+ return;
+ }
+
+ try {
+ Logger.debug(LOG_TAG, "Wiping form history and deleted form history...");
+ purgeDatabases(formsProvider);
+ Logger.debug(LOG_TAG, "Wiping form history and deleted form history... DONE");
+ } catch (Exception e) {
+ delegate.onWipeFailed(e);
+ return;
+ }
+
+ delegate.onWipeSucceeded();
+ }
+ };
+ storeWorkQueue.execute(command);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/PasswordsRepositorySession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/PasswordsRepositorySession.java
new file mode 100644
index 000000000..f7b7416df
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/PasswordsRepositorySession.java
@@ -0,0 +1,725 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.DeletedColumns;
+import org.mozilla.gecko.db.BrowserContract.DeletedPasswords;
+import org.mozilla.gecko.db.BrowserContract.Passwords;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.NoStoreDelegateException;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.RecordFilter;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.StoreTrackingRepositorySession;
+import org.mozilla.gecko.sync.repositories.android.RepoUtils.QueryHelper;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
+import org.mozilla.gecko.sync.repositories.domain.PasswordRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import android.content.ContentProviderClient;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+
+public class PasswordsRepositorySession extends
+ StoreTrackingRepositorySession {
+
+ public static class PasswordsRepository extends Repository {
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ PasswordsRepositorySession session = new PasswordsRepositorySession(PasswordsRepository.this, context);
+ final RepositorySessionCreationDelegate deferredCreationDelegate = delegate.deferredCreationDelegate();
+ deferredCreationDelegate.onSessionCreated(session);
+ }
+ }
+
+ private static final String LOG_TAG = "PasswordsRepoSession";
+ private static final String COLLECTION = "passwords";
+
+ private final RepoUtils.QueryHelper passwordsHelper;
+ private final RepoUtils.QueryHelper deletedPasswordsHelper;
+ private final ContentProviderClient passwordsProvider;
+
+ private final Context context;
+
+ public PasswordsRepositorySession(Repository repository, Context context) {
+ super(repository);
+ this.context = context;
+ this.passwordsHelper = new QueryHelper(context, BrowserContractHelpers.PASSWORDS_CONTENT_URI, LOG_TAG);
+ this.deletedPasswordsHelper = new QueryHelper(context, BrowserContractHelpers.DELETED_PASSWORDS_CONTENT_URI, LOG_TAG);
+ this.passwordsProvider = context.getContentResolver().acquireContentProviderClient(BrowserContract.PASSWORDS_AUTHORITY_URI);
+ }
+
+ private static final String[] GUID_COLS = new String[] { Passwords.GUID };
+ private static final String[] DELETED_GUID_COLS = new String[] { DeletedColumns.GUID };
+
+ private static final String WHERE_GUID_IS = Passwords.GUID + " = ?";
+ private static final String WHERE_DELETED_GUID_IS = DeletedPasswords.GUID + " = ?";
+
+ @Override
+ public void guidsSince(final long timestamp, final RepositorySessionGuidsSinceDelegate delegate) {
+ final Runnable guidsSinceRunnable = new Runnable() {
+ @Override
+ public void run() {
+
+ if (!isActive()) {
+ delegate.onGuidsSinceFailed(new InactiveSessionException(null));
+ return;
+ }
+
+ // Checks succeeded, now get GUIDs.
+ final List<String> guids = new ArrayList<String>();
+ try {
+ Logger.debug(LOG_TAG, "Fetching guidsSince from data table.");
+ final Cursor data = passwordsHelper.safeQuery(passwordsProvider, ".getGUIDsSince", GUID_COLS, dateModifiedWhere(timestamp), null, null);
+ try {
+ if (data.moveToFirst()) {
+ while (!data.isAfterLast()) {
+ guids.add(RepoUtils.getStringFromCursor(data, Passwords.GUID));
+ data.moveToNext();
+ }
+ }
+ } finally {
+ data.close();
+ }
+
+ // Fetch guids from deleted table.
+ Logger.debug(LOG_TAG, "Fetching guidsSince from deleted table.");
+ final Cursor deleted = deletedPasswordsHelper.safeQuery(passwordsProvider, ".getGUIDsSince", DELETED_GUID_COLS, dateModifiedWhereDeleted(timestamp), null, null);
+ try {
+ if (deleted.moveToFirst()) {
+ while (!deleted.isAfterLast()) {
+ guids.add(RepoUtils.getStringFromCursor(deleted, DeletedColumns.GUID));
+ deleted.moveToNext();
+ }
+ }
+ } finally {
+ deleted.close();
+ }
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Exception in fetch.");
+ delegate.onGuidsSinceFailed(e);
+ return;
+ }
+ String[] guidStrings = new String[guids.size()];
+ delegate.onGuidsSinceSucceeded(guids.toArray(guidStrings));
+ }
+ };
+
+ delegateQueue.execute(guidsSinceRunnable);
+ }
+
+ @Override
+ public void fetchSince(final long timestamp, final RepositorySessionFetchRecordsDelegate delegate) {
+ final RecordFilter filter = this.storeTracker.getFilter();
+ final Runnable fetchSinceRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (!isActive()) {
+ delegate.onFetchFailed(new InactiveSessionException(null), null);
+ return;
+ }
+
+ final long end = now();
+ try {
+ // Fetch from data table.
+ Cursor data = passwordsHelper.safeQuery(passwordsProvider, ".fetchSince",
+ getAllColumns(),
+ dateModifiedWhere(timestamp),
+ null, null);
+ if (!fetchAndCloseCursorDeleted(data, false, filter, delegate)) {
+ return;
+ }
+
+ // Fetch from deleted table.
+ Cursor deleted = deletedPasswordsHelper.safeQuery(passwordsProvider, ".fetchSince",
+ getAllDeletedColumns(),
+ dateModifiedWhereDeleted(timestamp),
+ null, null);
+ if (!fetchAndCloseCursorDeleted(deleted, true, filter, delegate)) {
+ return;
+ }
+
+ // Success!
+ try {
+ delegate.onFetchCompleted(end);
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Delegate fetch completed callback failed.", e);
+ // Don't call failure callback.
+ return;
+ }
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Exception in fetch.");
+ delegate.onFetchFailed(e, null);
+ }
+ }
+ };
+
+ delegateQueue.execute(fetchSinceRunnable);
+ }
+
+ @Override
+ public void fetch(final String[] guids, final RepositorySessionFetchRecordsDelegate delegate) {
+ if (guids == null || guids.length < 1) {
+ Logger.error(LOG_TAG, "No guids to be fetched.");
+ final long end = now();
+ delegateQueue.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.onFetchCompleted(end);
+ }
+ });
+ return;
+ }
+
+ // Checks succeeded, now fetch.
+ final RecordFilter filter = this.storeTracker.getFilter();
+ final Runnable fetchRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (!isActive()) {
+ delegate.onFetchFailed(new InactiveSessionException(null), null);
+ return;
+ }
+
+ final long end = now();
+ final String where = RepoUtils.computeSQLInClause(guids.length, "guid");
+ Logger.trace(LOG_TAG, "Fetch guids where: " + where);
+
+ try {
+ // Fetch records from data table.
+ Cursor data = passwordsHelper.safeQuery(passwordsProvider, ".fetch",
+ getAllColumns(),
+ where, guids, null);
+ if (!fetchAndCloseCursorDeleted(data, false, filter, delegate)) {
+ return;
+ }
+
+ // Fetch records from deleted table.
+ Cursor deleted = deletedPasswordsHelper.safeQuery(passwordsProvider, ".fetch",
+ getAllDeletedColumns(),
+ where, guids, null);
+ if (!fetchAndCloseCursorDeleted(deleted, true, filter, delegate)) {
+ return;
+ }
+
+ delegate.onFetchCompleted(end);
+
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Exception in fetch.");
+ delegate.onFetchFailed(e, null);
+ }
+ }
+ };
+
+ delegateQueue.execute(fetchRunnable);
+ }
+
+ @Override
+ public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) {
+ fetchSince(0, delegate);
+ }
+
+ @Override
+ public void store(final Record record) throws NoStoreDelegateException {
+ if (delegate == null) {
+ Logger.error(LOG_TAG, "No store delegate.");
+ throw new NoStoreDelegateException();
+ }
+ if (record == null) {
+ Logger.error(LOG_TAG, "Record sent to store was null.");
+ throw new IllegalArgumentException("Null record passed to PasswordsRepositorySession.store().");
+ }
+ if (!(record instanceof PasswordRecord)) {
+ Logger.error(LOG_TAG, "Can't store anything but a PasswordRecord.");
+ throw new IllegalArgumentException("Non-PasswordRecord passed to PasswordsRepositorySession.store().");
+ }
+
+ final PasswordRecord remoteRecord = (PasswordRecord) record;
+
+ final Runnable storeRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (!isActive()) {
+ Logger.warn(LOG_TAG, "RepositorySession is inactive. Store failing.");
+ delegate.onRecordStoreFailed(new InactiveSessionException(null), record.guid);
+ return;
+ }
+
+ final String guid = remoteRecord.guid;
+ if (guid == null) {
+ delegate.onRecordStoreFailed(new RuntimeException("Can't store record with null GUID."), record.guid);
+ return;
+ }
+
+ PasswordRecord existingRecord;
+ try {
+ existingRecord = retrieveByGUID(guid);
+ } catch (NullCursorException | RemoteException e) {
+ // Indicates a serious problem.
+ delegate.onRecordStoreFailed(e, record.guid);
+ return;
+ }
+
+ long lastLocalRetrieval = 0; // lastSyncTimestamp?
+ long lastRemoteRetrieval = 0; // TODO: adjust for clock skew.
+ boolean remotelyModified = remoteRecord.lastModified > lastRemoteRetrieval;
+
+ // Check deleted state first.
+ if (remoteRecord.deleted) {
+ if (existingRecord == null) {
+ // Do nothing, record does not exist anyways.
+ Logger.info(LOG_TAG, "Incoming record " + remoteRecord.guid + " is deleted, and no local version.");
+ return;
+ }
+
+ if (existingRecord.deleted) {
+ // Record is already tracked as deleted. Delete from local.
+ storeRecordDeletion(existingRecord); // different from ABRepoSess.
+ Logger.info(LOG_TAG, "Incoming record " + remoteRecord.guid + " and local are both deleted.");
+ return;
+ }
+
+ // Which one wins?
+ if (!remotelyModified) {
+ trace("Ignoring deleted record from the past.");
+ return;
+ }
+
+ boolean locallyModified = existingRecord.lastModified > lastLocalRetrieval;
+ if (!locallyModified) {
+ trace("Remote modified, local not. Deleting.");
+ storeRecordDeletion(remoteRecord);
+ return;
+ }
+
+ trace("Both local and remote records have been modified.");
+ if (remoteRecord.lastModified > existingRecord.lastModified) {
+ trace("Remote is newer, and deleted. Deleting local.");
+ storeRecordDeletion(remoteRecord);
+ return;
+ }
+
+ trace("Remote is older, local is not deleted. Ignoring.");
+ if (!locallyModified) {
+ Logger.warn(LOG_TAG, "Inconsistency: old remote record is deleted, but local record not modified!");
+ // Ensure that this is tracked for upload.
+ }
+ return;
+ }
+ // End deletion logic.
+
+ // Validate the incoming record.
+ if (!remoteRecord.isValid()) {
+ Logger.warn(LOG_TAG, "Incoming record is invalid. Reporting store failed.");
+ delegate.onRecordStoreFailed(new RuntimeException("Can't store invalid password record."), record.guid);
+ return;
+ }
+
+ // Now we're processing a non-deleted incoming record.
+ if (existingRecord == null) {
+ trace("Looking up match for record " + remoteRecord.guid);
+ try {
+ existingRecord = findExistingRecord(remoteRecord);
+ } catch (RemoteException e) {
+ Logger.error(LOG_TAG, "Remote exception in findExistingRecord.");
+ delegate.onRecordStoreFailed(e, record.guid);
+ } catch (NullCursorException e) {
+ Logger.error(LOG_TAG, "Null cursor in findExistingRecord.");
+ delegate.onRecordStoreFailed(e, record.guid);
+ }
+ }
+
+ if (existingRecord == null) {
+ // The record is new.
+ trace("No match. Inserting.");
+ Logger.debug(LOG_TAG, "Didn't find matching record. Inserting.");
+ Record inserted = null;
+ try {
+ inserted = insert(remoteRecord);
+ } catch (RemoteException e) {
+ Logger.debug(LOG_TAG, "Record insert caused a RemoteException.");
+ delegate.onRecordStoreFailed(e, record.guid);
+ return;
+ }
+ trackRecord(inserted);
+ delegate.onRecordStoreSucceeded(inserted.guid);
+ return;
+ }
+
+ // We found a local dupe.
+ trace("Incoming record " + remoteRecord.guid + " dupes to local record " + existingRecord.guid);
+ Logger.debug(LOG_TAG, "remote " + remoteRecord.guid + " dupes to " + existingRecord.guid);
+
+ if (existingRecord.deleted && existingRecord.lastModified > remoteRecord.lastModified) {
+ Logger.debug(LOG_TAG, "Local deletion is newer, not storing remote record.");
+ return;
+ }
+
+ Record toStore = reconcileRecords(remoteRecord, existingRecord, lastRemoteRetrieval, lastLocalRetrieval);
+ if (toStore == null) {
+ Logger.debug(LOG_TAG, "Reconciling returned null. Not inserting a record.");
+ return;
+ }
+
+ // TODO: pass in timestamps?
+ Logger.debug(LOG_TAG, "Replacing " + existingRecord.guid + " with record " + toStore.guid);
+ Record replaced = null;
+ try {
+ replaced = replace(existingRecord, toStore);
+ } catch (RemoteException e) {
+ Logger.debug(LOG_TAG, "Record replace caused a RemoteException.");
+ delegate.onRecordStoreFailed(e, record.guid);
+ return;
+ }
+
+ // Note that we don't track records here; deciding that is the job
+ // of reconcileRecords.
+ Logger.debug(LOG_TAG, "Calling delegate callback with guid " + replaced.guid +
+ "(" + replaced.androidID + ")");
+ delegate.onRecordStoreSucceeded(record.guid);
+ return;
+ }
+ };
+ storeWorkQueue.execute(storeRunnable);
+ }
+
+ @Override
+ public void wipe(final RepositorySessionWipeDelegate delegate) {
+ Logger.info(LOG_TAG, "Wiping " + BrowserContractHelpers.PASSWORDS_CONTENT_URI + ", " + BrowserContractHelpers.DELETED_PASSWORDS_CONTENT_URI);
+
+ Runnable wipeRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (!isActive()) {
+ delegate.onWipeFailed(new InactiveSessionException(null));
+ return;
+ }
+
+ // Wipe both data and deleted.
+ try {
+ context.getContentResolver().delete(BrowserContractHelpers.PASSWORDS_CONTENT_URI, null, null);
+ context.getContentResolver().delete(BrowserContractHelpers.DELETED_PASSWORDS_CONTENT_URI, null, null);
+ } catch (Exception e) {
+ delegate.onWipeFailed(e);
+ return;
+ }
+ delegate.onWipeSucceeded();
+ }
+ };
+ storeWorkQueue.execute(wipeRunnable);
+ }
+
+ @Override
+ public void abort() {
+ passwordsProvider.release();
+ super.abort();
+ }
+
+ @Override
+ public void finish(final RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
+ passwordsProvider.release();
+ super.finish(delegate);
+ }
+
+ public void deleteGUID(String guid) throws RemoteException {
+ final String[] args = new String[] { guid };
+
+ int deleted = passwordsProvider.delete(BrowserContractHelpers.PASSWORDS_CONTENT_URI, WHERE_GUID_IS, args) +
+ passwordsProvider.delete(BrowserContractHelpers.DELETED_PASSWORDS_CONTENT_URI, WHERE_DELETED_GUID_IS, args);
+ if (deleted == 1) {
+ return;
+ }
+ Logger.warn(LOG_TAG, "Unexpectedly deleted " + deleted + " rows for guid " + guid);
+ }
+
+ /**
+ * Insert record and return the record with its updated androidId set.
+ *
+ * @param record the record to insert.
+ * @return updated record.
+ * @throws RemoteException
+ */
+ public PasswordRecord insert(PasswordRecord record) throws RemoteException {
+ record.timePasswordChanged = now();
+ // TODO: are these necessary for Fennec autocomplete?
+ // record.timesUsed = 1;
+ // record.timeLastUsed = now();
+ ContentValues cv = getContentValues(record);
+ Uri insertedUri = passwordsProvider.insert(BrowserContractHelpers.PASSWORDS_CONTENT_URI, cv);
+ if (insertedUri == null) {
+ throw new RemoteException(); // Not much to be done here, save throw.
+ }
+ record.androidID = ContentUris.parseId(insertedUri);
+ return record;
+ }
+
+ public Record replace(Record origRecord, Record newRecord) throws RemoteException {
+ PasswordRecord newPasswordRecord = (PasswordRecord) newRecord;
+ PasswordRecord origPasswordRecord = (PasswordRecord) origRecord;
+ propagateTimes(newPasswordRecord, origPasswordRecord);
+ ContentValues cv = getContentValues(newPasswordRecord);
+
+ final String[] args = new String[] { origRecord.guid };
+
+ if (origRecord.deleted) {
+ // Purge from deleted table.
+ deleteGUID(origRecord.guid);
+ insert(newPasswordRecord);
+ } else {
+ int updated = context.getContentResolver().update(BrowserContractHelpers.PASSWORDS_CONTENT_URI, cv, WHERE_GUID_IS, args);
+ if (updated != 1) {
+ Logger.warn(LOG_TAG, "Unexpectedly updated " + updated + " rows for guid " + origPasswordRecord.guid);
+ }
+ }
+
+ return newRecord;
+ }
+
+ // When replacing a record, propagate the times.
+ private static void propagateTimes(PasswordRecord toRecord, PasswordRecord fromRecord) {
+ toRecord.timePasswordChanged = now();
+ toRecord.timeCreated = fromRecord.timeCreated;
+ toRecord.timeLastUsed = fromRecord.timeLastUsed;
+ toRecord.timesUsed = fromRecord.timesUsed;
+ }
+
+ private static String[] getAllColumns() {
+ return BrowserContractHelpers.PasswordColumns;
+ }
+
+ private static String[] getAllDeletedColumns() {
+ return BrowserContractHelpers.DeletedColumns;
+ }
+
+ /**
+ * Constructs the DB query string for entry age for deleted records.
+ *
+ * @param timestamp
+ * @return String DB query string for dates to fetch.
+ */
+ private static String dateModifiedWhereDeleted(long timestamp) {
+ return DeletedColumns.TIME_DELETED + " >= " + Long.toString(timestamp);
+ }
+
+ /**
+ * Constructs the DB query string for entry age for (undeleted) records.
+ *
+ * @param timestamp
+ * @return String DB query string for dates to fetch.
+ */
+ private static String dateModifiedWhere(long timestamp) {
+ return Passwords.TIME_PASSWORD_CHANGED + " >= " + Long.toString(timestamp);
+ }
+
+
+ /**
+ * Fetch from the cursor with the given parameters, invoking
+ * delegate callbacks and closing the cursor.
+ * Returns true on success, false if failure was signaled.
+ *
+ * @param cursor
+ fetch* cursor.
+ * @param deleted
+ * true if using deleted table, false when using data table.
+ * @param delegate
+ * FetchRecordsDelegate to process records.
+ */
+ private static boolean fetchAndCloseCursorDeleted(final Cursor cursor,
+ final boolean deleted,
+ final RecordFilter filter,
+ final RepositorySessionFetchRecordsDelegate delegate) {
+ if (cursor == null) {
+ return true;
+ }
+
+ try {
+ while (cursor.moveToNext()) {
+ Record r = deleted ? deletedPasswordRecordFromCursor(cursor) : passwordRecordFromCursor(cursor);
+ if (r != null) {
+ if (filter == null || !filter.excludeRecord(r)) {
+ Logger.debug(LOG_TAG, "Processing record " + r.guid);
+ delegate.onFetchedRecord(r);
+ } else {
+ Logger.debug(LOG_TAG, "Skipping filtered record " + r.guid);
+ }
+ }
+ }
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Exception in fetch.");
+ delegate.onFetchFailed(e, null);
+ return false;
+ } finally {
+ cursor.close();
+ }
+
+ return true;
+ }
+
+ private PasswordRecord retrieveByGUID(String guid) throws NullCursorException, RemoteException {
+ final String[] guidArg = new String[] { guid };
+
+ // Check data table.
+ final Cursor data = passwordsHelper.safeQuery(passwordsProvider, ".store", BrowserContractHelpers.PasswordColumns, WHERE_GUID_IS, guidArg, null);
+ try {
+ if (data.moveToFirst()) {
+ return passwordRecordFromCursor(data);
+ }
+ } finally {
+ data.close();
+ }
+
+ // Check deleted table.
+ final Cursor deleted = deletedPasswordsHelper.safeQuery(passwordsProvider, ".retrieveByGuid", BrowserContractHelpers.DeletedColumns, WHERE_DELETED_GUID_IS, guidArg, null);
+ try {
+ if (deleted.moveToFirst()) {
+ return deletedPasswordRecordFromCursor(deleted);
+ }
+ } finally {
+ deleted.close();
+ }
+
+ return null;
+ }
+
+ private static final String WHERE_RECORD_DATA =
+ Passwords.HOSTNAME + " = ? AND " +
+ Passwords.HTTP_REALM + " = ? AND " +
+ Passwords.FORM_SUBMIT_URL + " = ? AND " +
+ Passwords.USERNAME_FIELD + " = ? AND " +
+ Passwords.PASSWORD_FIELD + " = ?";
+
+ private PasswordRecord findExistingRecord(PasswordRecord record) throws NullCursorException, RemoteException {
+ PasswordRecord foundRecord = null;
+ Cursor cursor = null;
+ // Only check the data table.
+ // We can't encrypt username directly for query, so run a more general query and then filter.
+ final String[] whereArgs = new String[] {
+ record.hostname,
+ record.httpRealm,
+ record.formSubmitURL,
+ record.usernameField,
+ record.passwordField
+ };
+
+ try {
+ cursor = passwordsHelper.safeQuery(passwordsProvider, ".findRecord", getAllColumns(), WHERE_RECORD_DATA, whereArgs, null);
+ while (cursor.moveToNext()) {
+ foundRecord = passwordRecordFromCursor(cursor);
+
+ // We don't directly query for username because the
+ // username/password values are encrypted in the db.
+ // We don't have the keys for encrypting our query,
+ // so we run a more general query and then filter
+ // the returned records for a matching username.
+ Logger.pii(LOG_TAG, "Checking incoming [" + record.encryptedUsername + "] to [" + foundRecord.encryptedUsername + "]");
+ if (record.encryptedUsername.equals(foundRecord.encryptedUsername)) {
+ Logger.trace(LOG_TAG, "Found matching record: " + foundRecord.guid);
+ return foundRecord;
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ Logger.debug(LOG_TAG, "No matching records, returning null.");
+ return null;
+ }
+
+ private void storeRecordDeletion(Record record) {
+ try {
+ deleteGUID(record.guid);
+ } catch (RemoteException e) {
+ Logger.error(LOG_TAG, "RemoteException in password delete.");
+ delegate.onRecordStoreFailed(e, record.guid);
+ return;
+ }
+ delegate.onRecordStoreSucceeded(record.guid);
+ }
+
+ /**
+ * Make a PasswordRecord from a Cursor.
+ * @param cur
+ * Cursor from query.
+ * @param deleted
+ * true if creating a deleted Record, false if otherwise.
+ * @return
+ * PasswordRecord populated from Cursor.
+ */
+ private static PasswordRecord passwordRecordFromCursor(Cursor cur) {
+ if (cur.isAfterLast()) {
+ return null;
+ }
+ String guid = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.GUID);
+ long lastModified = RepoUtils.getLongFromCursor(cur, BrowserContract.Passwords.TIME_PASSWORD_CHANGED);
+
+ PasswordRecord rec = new PasswordRecord(guid, COLLECTION, lastModified, false);
+ rec.id = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.ID);
+ rec.hostname = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.HOSTNAME);
+ rec.httpRealm = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.HTTP_REALM);
+ rec.formSubmitURL = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.FORM_SUBMIT_URL);
+ rec.usernameField = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.USERNAME_FIELD);
+ rec.passwordField = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.PASSWORD_FIELD);
+ rec.encType = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.ENC_TYPE);
+
+ // TODO decryption of username/password here (Bug 711636)
+ rec.encryptedUsername = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.ENCRYPTED_USERNAME);
+ rec.encryptedPassword = RepoUtils.getStringFromCursor(cur, BrowserContract.Passwords.ENCRYPTED_PASSWORD);
+
+ rec.timeCreated = RepoUtils.getLongFromCursor(cur, BrowserContract.Passwords.TIME_CREATED);
+ rec.timeLastUsed = RepoUtils.getLongFromCursor(cur, BrowserContract.Passwords.TIME_LAST_USED);
+ rec.timePasswordChanged = RepoUtils.getLongFromCursor(cur, BrowserContract.Passwords.TIME_PASSWORD_CHANGED);
+ rec.timesUsed = RepoUtils.getLongFromCursor(cur, BrowserContract.Passwords.TIMES_USED);
+ return rec;
+ }
+
+ private static PasswordRecord deletedPasswordRecordFromCursor(Cursor cur) {
+ if (cur.isAfterLast()) {
+ return null;
+ }
+ String guid = RepoUtils.getStringFromCursor(cur, DeletedColumns.GUID);
+ long lastModified = RepoUtils.getLongFromCursor(cur, DeletedColumns.TIME_DELETED);
+ PasswordRecord rec = new PasswordRecord(guid, COLLECTION, lastModified, true);
+ rec.androidID = RepoUtils.getLongFromCursor(cur, DeletedColumns.ID);
+ return rec;
+ }
+
+ private static ContentValues getContentValues(Record record) {
+ PasswordRecord rec = (PasswordRecord) record;
+
+ ContentValues cv = new ContentValues();
+ cv.put(BrowserContract.Passwords.GUID, rec.guid);
+ cv.put(BrowserContract.Passwords.HOSTNAME, rec.hostname);
+ cv.put(BrowserContract.Passwords.HTTP_REALM, rec.httpRealm);
+ cv.put(BrowserContract.Passwords.FORM_SUBMIT_URL, rec.formSubmitURL);
+ cv.put(BrowserContract.Passwords.USERNAME_FIELD, rec.usernameField);
+ cv.put(BrowserContract.Passwords.PASSWORD_FIELD, rec.passwordField);
+
+ // TODO Do encryption of username/password here. Bug 711636
+ cv.put(BrowserContract.Passwords.ENC_TYPE, rec.encType);
+ cv.put(BrowserContract.Passwords.ENCRYPTED_USERNAME, rec.encryptedUsername);
+ cv.put(BrowserContract.Passwords.ENCRYPTED_PASSWORD, rec.encryptedPassword);
+
+ cv.put(BrowserContract.Passwords.TIME_CREATED, rec.timeCreated);
+ cv.put(BrowserContract.Passwords.TIME_LAST_USED, rec.timeLastUsed);
+ cv.put(BrowserContract.Passwords.TIME_PASSWORD_CHANGED, rec.timePasswordChanged);
+ cv.put(BrowserContract.Passwords.TIMES_USED, rec.timesUsed);
+ return cv;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/RepoUtils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/RepoUtils.java
new file mode 100644
index 000000000..9c29953f8
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/RepoUtils.java
@@ -0,0 +1,290 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import android.content.ContentProviderClient;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.os.RemoteException;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonArrayJSONException;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.mozilla.gecko.sync.repositories.domain.HistoryRecord;
+
+import java.io.IOException;
+
+public class RepoUtils {
+
+ private static final String LOG_TAG = "RepoUtils";
+
+ /**
+ * A helper class for monotonous SQL querying. Does timing and logging,
+ * offers a utility to throw on a null cursor.
+ *
+ * @author rnewman
+ *
+ */
+ public static class QueryHelper {
+ private final Context context;
+ private final Uri uri;
+ private final String tag;
+
+ public QueryHelper(Context context, Uri uri, String tag) {
+ this.context = context;
+ this.uri = uri;
+ this.tag = tag;
+ }
+
+ // For ContentProvider queries.
+ public Cursor safeQuery(String label, String[] projection,
+ String selection, String[] selectionArgs, String sortOrder) throws NullCursorException {
+ long queryStart = android.os.SystemClock.uptimeMillis();
+ Cursor c = context.getContentResolver().query(uri, projection, selection, selectionArgs, sortOrder);
+ return checkAndLogCursor(label, queryStart, c);
+ }
+
+ public Cursor safeQuery(String[] projection, String selection, String[] selectionArgs, String sortOrder) throws NullCursorException {
+ return this.safeQuery(null, projection, selection, selectionArgs, sortOrder);
+ }
+
+ // For ContentProviderClient queries.
+ public Cursor safeQuery(ContentProviderClient client, String label, String[] projection,
+ String selection, String[] selectionArgs, String sortOrder) throws NullCursorException, RemoteException {
+ long queryStart = android.os.SystemClock.uptimeMillis();
+ Cursor c = client.query(uri, projection, selection, selectionArgs, sortOrder);
+ return checkAndLogCursor(label, queryStart, c);
+ }
+
+ // For SQLiteOpenHelper queries.
+ public Cursor safeQuery(SQLiteDatabase db, String label, String table, String[] columns,
+ String selection, String[] selectionArgs,
+ String groupBy, String having, String orderBy, String limit) throws NullCursorException {
+ long queryStart = android.os.SystemClock.uptimeMillis();
+ Cursor c = db.query(table, columns, selection, selectionArgs, groupBy, having, orderBy, limit);
+ return checkAndLogCursor(label, queryStart, c);
+ }
+
+ public Cursor safeQuery(SQLiteDatabase db, String label, String table, String[] columns,
+ String selection, String[] selectionArgs) throws NullCursorException {
+ return safeQuery(db, label, table, columns, selection, selectionArgs, null, null, null, null);
+ }
+
+ private Cursor checkAndLogCursor(String label, long queryStart, Cursor c) throws NullCursorException {
+ long queryEnd = android.os.SystemClock.uptimeMillis();
+ String logLabel = (label == null) ? tag : (tag + label);
+ RepoUtils.queryTimeLogger(logLabel, queryStart, queryEnd);
+ return checkNullCursor(logLabel, c);
+ }
+
+ public Cursor checkNullCursor(String logLabel, Cursor cursor) throws NullCursorException {
+ if (cursor == null) {
+ Logger.error(tag, "Got null cursor exception in " + logLabel);
+ throw new NullCursorException(null);
+ }
+ return cursor;
+ }
+ }
+
+ /**
+ * This method exists because the behavior of <code>cur.getString()</code> is undefined
+ * when the value in the database is <code>NULL</code>.
+ * This method will return <code>null</code> in that case.
+ */
+ public static String optStringFromCursor(final Cursor cur, final String colId) {
+ final int col = cur.getColumnIndex(colId);
+ if (cur.isNull(col)) {
+ return null;
+ }
+ return cur.getString(col);
+ }
+
+ /**
+ * The behavior of this method when the value in the database is <code>NULL</code> is
+ * determined by the implementation of the {@link Cursor}.
+ */
+ public static String getStringFromCursor(final Cursor cur, final String colId) {
+ // TODO: getColumnIndexOrThrow?
+ // TODO: don't look up columns by name!
+ return cur.getString(cur.getColumnIndex(colId));
+ }
+
+ public static long getLongFromCursor(Cursor cur, String colId) {
+ return cur.getLong(cur.getColumnIndex(colId));
+ }
+
+ public static int getIntFromCursor(Cursor cur, String colId) {
+ return cur.getInt(cur.getColumnIndex(colId));
+ }
+
+ public static JSONArray getJSONArrayFromCursor(Cursor cur, String colId) {
+ String jsonArrayAsString = getStringFromCursor(cur, colId);
+ if (jsonArrayAsString == null) {
+ return new JSONArray();
+ }
+ try {
+ return ExtendedJSONObject.parseJSONArray(getStringFromCursor(cur, colId));
+ } catch (NonArrayJSONException e) {
+ Logger.error(LOG_TAG, "JSON parsing error for " + colId, e);
+ return null;
+ } catch (IOException e) {
+ Logger.error(LOG_TAG, "JSON parsing error for " + colId, e);
+ return null;
+ }
+ }
+
+ /**
+ * Return true if the provided URI is non-empty and acceptable to Fennec
+ * (i.e., not an undesirable scheme).
+ *
+ * This code is pilfered from Fennec, which pilfered from Places.
+ */
+ public static boolean isValidHistoryURI(String uri) {
+ if (uri == null || uri.length() == 0) {
+ return false;
+ }
+
+ // First, check the most common cases (HTTP, HTTPS) to avoid most of the work.
+ if (uri.startsWith("http:") || uri.startsWith("https:")) {
+ return true;
+ }
+
+ String scheme = Uri.parse(uri).getScheme();
+ if (scheme == null) {
+ return false;
+ }
+
+ // Now check for all bad things.
+ if (scheme.equals("about") ||
+ scheme.equals("imap") ||
+ scheme.equals("news") ||
+ scheme.equals("mailbox") ||
+ scheme.equals("moz-anno") ||
+ scheme.equals("view-source") ||
+ scheme.equals("chrome") ||
+ scheme.equals("resource") ||
+ scheme.equals("data") ||
+ scheme.equals("wyciwyg") ||
+ scheme.equals("javascript")) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Create a HistoryRecord object from a cursor row.
+ *
+ * @return a HistoryRecord, or null if this row would produce
+ * an invalid record (e.g., with a null URI or no visits).
+ */
+ public static HistoryRecord historyFromMirrorCursor(Cursor cur) {
+ final String guid = getStringFromCursor(cur, BrowserContract.SyncColumns.GUID);
+ if (guid == null) {
+ Logger.debug(LOG_TAG, "Skipping history record with null GUID.");
+ return null;
+ }
+
+ final String historyURI = getStringFromCursor(cur, BrowserContract.History.URL);
+ if (!isValidHistoryURI(historyURI)) {
+ Logger.debug(LOG_TAG, "Skipping history record " + guid + " with unwanted/invalid URI " + historyURI);
+ return null;
+ }
+
+ final long visitCount = getLongFromCursor(cur, BrowserContract.History.VISITS);
+ if (visitCount <= 0) {
+ Logger.debug(LOG_TAG, "Skipping history record " + guid + " with <= 0 visit count.");
+ return null;
+ }
+
+ final String collection = "history";
+ final long lastModified = getLongFromCursor(cur, BrowserContract.SyncColumns.DATE_MODIFIED);
+ final boolean deleted = getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) == 1;
+
+ final HistoryRecord rec = new HistoryRecord(guid, collection, lastModified, deleted);
+
+ rec.androidID = getLongFromCursor(cur, BrowserContract.History._ID);
+ rec.fennecDateVisited = getLongFromCursor(cur, BrowserContract.History.DATE_LAST_VISITED);
+ rec.fennecVisitCount = visitCount;
+ rec.histURI = historyURI;
+ rec.title = getStringFromCursor(cur, BrowserContract.History.TITLE);
+
+ return logHistory(rec);
+ }
+
+ private static HistoryRecord logHistory(HistoryRecord rec) {
+ try {
+ Logger.debug(LOG_TAG, "Returning history record " + rec.guid + " (" + rec.androidID + ")");
+ Logger.debug(LOG_TAG, "> Visited: " + rec.fennecDateVisited);
+ Logger.debug(LOG_TAG, "> Visits: " + rec.fennecVisitCount);
+ if (Logger.LOG_PERSONAL_INFORMATION) {
+ Logger.pii(LOG_TAG, "> Title: " + rec.title);
+ Logger.pii(LOG_TAG, "> URI: " + rec.histURI);
+ }
+ } catch (Exception e) {
+ Logger.debug(LOG_TAG, "Exception logging history record " + rec, e);
+ }
+ return rec;
+ }
+
+ public static void logClient(ClientRecord rec) {
+ if (Logger.shouldLogVerbose(LOG_TAG)) {
+ Logger.trace(LOG_TAG, "Returning client record " + rec.guid + " (" + rec.androidID + ")");
+ Logger.trace(LOG_TAG, "Client Name: " + rec.name);
+ Logger.trace(LOG_TAG, "Client Type: " + rec.type);
+ Logger.trace(LOG_TAG, "Last Modified: " + rec.lastModified);
+ Logger.trace(LOG_TAG, "Deleted: " + rec.deleted);
+ }
+ }
+
+ public static void queryTimeLogger(String methodCallingQuery, long queryStart, long queryEnd) {
+ long elapsedTime = queryEnd - queryStart;
+ Logger.debug(LOG_TAG, "Query timer: " + methodCallingQuery + " took " + elapsedTime + "ms.");
+ }
+
+ public static boolean stringsEqual(String a, String b) {
+ // Check for nulls
+ if (a == b) return true;
+ if (a == null && b != null) return false;
+ if (a != null && b == null) return false;
+
+ return a.equals(b);
+ }
+
+ public static String computeSQLLongInClause(long[] items, String field) {
+ final StringBuilder builder = new StringBuilder(field);
+ builder.append(" IN (");
+ int i = 0;
+ for (; i < items.length - 1; ++i) {
+ builder.append(items[i]);
+ builder.append(", ");
+ }
+ if (i < items.length) {
+ builder.append(items[i]);
+ }
+ builder.append(")");
+ return builder.toString();
+ }
+
+ public static String computeSQLInClause(int items, String field) {
+ final StringBuilder builder = new StringBuilder(field);
+ builder.append(" IN (");
+ int i = 0;
+ for (; i < items - 1; ++i) {
+ builder.append("?, ");
+ }
+ if (i < items) {
+ builder.append("?");
+ }
+ builder.append(")");
+ return builder.toString();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/VisitsHelper.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/VisitsHelper.java
new file mode 100644
index 000000000..9ba784759
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/VisitsHelper.java
@@ -0,0 +1,130 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.support.annotation.NonNull;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.db.BrowserContract.Visits;
+
+/**
+ * This class is used by History Sync code (see <code>AndroidBrowserHistoryDataAccessor</code> and <code>AndroidBrowserHistoryRepositorySession</code>,
+ * and provides utility functions for working with history visits. Primarily we're either inserting visits
+ * into local database based on data received from Sync, or we're preparing local visits for upload into Sync.
+ */
+public class VisitsHelper {
+ public static final boolean DEFAULT_IS_LOCAL_VALUE = false;
+ public static final String SYNC_TYPE_KEY = "type";
+ public static final String SYNC_DATE_KEY = "date";
+
+ /**
+ * Returns a list of ContentValues of visits ready for insertion for a provided History GUID.
+ * Visits must have data and type. See <code>getVisitContentValues</code>.
+ *
+ * @param guid History GUID to use when inserting visit records
+ * @param visits <code>JSONArray</code> list of (date, type) tuples for visits
+ * @return visits ready for insertion
+ */
+ public static ContentValues[] getVisitsContentValues(@NonNull String guid, @NonNull JSONArray visits) {
+ final ContentValues[] visitsToStore = new ContentValues[visits.size()];
+ final int visitCount = visits.size();
+
+ if (visitCount == 0) {
+ return visitsToStore;
+ }
+
+ for (int i = 0; i < visitCount; i++) {
+ visitsToStore[i] = getVisitContentValues(
+ guid, (JSONObject) visits.get(i), DEFAULT_IS_LOCAL_VALUE);
+ }
+ return visitsToStore;
+ }
+
+ /**
+ * Maps up to <code>limit</code> visits for a given history GUID to an array of JSONObjects with "date" and "type" keys
+ *
+ * @param contentClient <code>ContentProviderClient</code> to use for querying Visits table
+ * @param guid History GUID for which to return visits
+ * @param limit Will return at most this number of visits
+ * @return <code>JSONArray</code> of all visits found for given History GUID
+ */
+ public static JSONArray getRecentHistoryVisitsForGUID(@NonNull ContentProviderClient contentClient,
+ @NonNull String guid, int limit) throws RemoteException {
+ final JSONArray visits = new JSONArray();
+
+ final Cursor cursor = contentClient.query(
+ visitsUriWithLimit(limit),
+ new String[] {Visits.VISIT_TYPE, Visits.DATE_VISITED},
+ Visits.HISTORY_GUID + " = ?",
+ new String[] {guid}, null);
+ if (cursor == null) {
+ return visits;
+ }
+ try {
+ if (!cursor.moveToFirst()) {
+ return visits;
+ }
+
+ final int dateVisitedCol = cursor.getColumnIndexOrThrow(Visits.DATE_VISITED);
+ final int visitTypeCol = cursor.getColumnIndexOrThrow(Visits.VISIT_TYPE);
+
+ while (!cursor.isAfterLast()) {
+ insertTupleIntoVisitsUnchecked(visits,
+ cursor.getLong(visitTypeCol),
+ cursor.getLong(dateVisitedCol)
+ );
+ cursor.moveToNext();
+ }
+ } finally {
+ cursor.close();
+ }
+
+ return visits;
+ }
+
+ /**
+ * Constructs <code>ContentValues</code> object for a visit based on passed in parameters.
+ *
+ * @param visit <code>JSONObject</code> containing visit type and visit date keys for the visit
+ * @param guid History GUID with with to associate this visit
+ * @param isLocal Whether or not to mark this visit as local
+ * @return <code>ContentValues</code> with all visit values necessary for database insertion
+ * @throws IllegalArgumentException if visit object is missing date or type keys
+ */
+ public static ContentValues getVisitContentValues(@NonNull String guid, @NonNull JSONObject visit, boolean isLocal) {
+ if (!visit.containsKey(SYNC_DATE_KEY) || !visit.containsKey(SYNC_TYPE_KEY)) {
+ throw new IllegalArgumentException("Visit missing required keys");
+ }
+
+ final ContentValues cv = new ContentValues();
+ cv.put(Visits.HISTORY_GUID, guid);
+ cv.put(Visits.IS_LOCAL, isLocal ? Visits.VISIT_IS_LOCAL : Visits.VISIT_IS_REMOTE);
+ cv.put(Visits.VISIT_TYPE, (Long) visit.get(SYNC_TYPE_KEY));
+ cv.put(Visits.DATE_VISITED, (Long) visit.get(SYNC_DATE_KEY));
+
+ return cv;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void insertTupleIntoVisitsUnchecked(@NonNull final JSONArray visits, @NonNull Long type, @NonNull Long date) {
+ final JSONObject visit = new JSONObject();
+ visit.put(SYNC_TYPE_KEY, type);
+ visit.put(SYNC_DATE_KEY, date);
+ visits.add(visit);
+ }
+
+ private static Uri visitsUriWithLimit(int limit) {
+ return BrowserContractHelpers.VISITS_CONTENT_URI
+ .buildUpon()
+ .appendQueryParameter("limit", Integer.toString(limit))
+ .build();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferrableRepositorySessionCreationDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferrableRepositorySessionCreationDelegate.java
new file mode 100644
index 000000000..f292600e4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferrableRepositorySessionCreationDelegate.java
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import org.mozilla.gecko.sync.ThreadPool;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+
+public abstract class DeferrableRepositorySessionCreationDelegate implements RepositorySessionCreationDelegate {
+ @Override
+ public RepositorySessionCreationDelegate deferredCreationDelegate() {
+ final RepositorySessionCreationDelegate self = this;
+ return new RepositorySessionCreationDelegate() {
+
+ // TODO: rewrite to use ExecutorService.
+ @Override
+ public void onSessionCreated(final RepositorySession session) {
+ ThreadPool.run(new Runnable() {
+ @Override
+ public void run() {
+ self.onSessionCreated(session);
+ }});
+ }
+
+ @Override
+ public void onSessionCreateFailed(final Exception ex) {
+ ThreadPool.run(new Runnable() {
+ @Override
+ public void run() {
+ self.onSessionCreateFailed(ex);
+ }});
+ }
+
+ @Override
+ public RepositorySessionCreationDelegate deferredCreationDelegate() {
+ return this;
+ }
+ };
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionBeginDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionBeginDelegate.java
new file mode 100644
index 000000000..1ccdcce19
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionBeginDelegate.java
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import java.util.concurrent.ExecutorService;
+
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+
+public class DeferredRepositorySessionBeginDelegate implements RepositorySessionBeginDelegate {
+ private final RepositorySessionBeginDelegate inner;
+ private final ExecutorService executor;
+ public DeferredRepositorySessionBeginDelegate(final RepositorySessionBeginDelegate inner, final ExecutorService executor) {
+ this.inner = inner;
+ this.executor = executor;
+ }
+
+ @Override
+ public void onBeginSucceeded(final RepositorySession session) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ inner.onBeginSucceeded(session);
+ }
+ });
+ }
+
+ @Override
+ public void onBeginFailed(final Exception ex) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ inner.onBeginFailed(ex);
+ }
+ });
+ }
+
+ @Override
+ public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService newExecutor) {
+ if (newExecutor == executor) {
+ return this;
+ }
+ throw new IllegalArgumentException("Can't re-defer this delegate.");
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFetchRecordsDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFetchRecordsDelegate.java
new file mode 100644
index 000000000..1178d9b5b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFetchRecordsDelegate.java
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import java.util.concurrent.ExecutorService;
+
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+public class DeferredRepositorySessionFetchRecordsDelegate implements RepositorySessionFetchRecordsDelegate {
+ private final RepositorySessionFetchRecordsDelegate inner;
+ private final ExecutorService executor;
+ public DeferredRepositorySessionFetchRecordsDelegate(final RepositorySessionFetchRecordsDelegate inner, final ExecutorService executor) {
+ this.inner = inner;
+ this.executor = executor;
+ }
+
+ @Override
+ public void onFetchedRecord(final Record record) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ inner.onFetchedRecord(record);
+ }
+ });
+ }
+
+ @Override
+ public void onFetchFailed(final Exception ex, final Record record) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ inner.onFetchFailed(ex, record);
+ }
+ });
+ }
+
+ @Override
+ public void onFetchCompleted(final long fetchEnd) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ inner.onFetchCompleted(fetchEnd);
+ }
+ });
+ }
+
+ @Override
+ public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService newExecutor) {
+ if (newExecutor == executor) {
+ return this;
+ }
+ throw new IllegalArgumentException("Can't re-defer this delegate.");
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFinishDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFinishDelegate.java
new file mode 100644
index 000000000..dbe7e4327
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionFinishDelegate.java
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import java.util.concurrent.ExecutorService;
+
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+
+public class DeferredRepositorySessionFinishDelegate implements
+ RepositorySessionFinishDelegate {
+ protected final ExecutorService executor;
+ protected final RepositorySessionFinishDelegate inner;
+
+ public DeferredRepositorySessionFinishDelegate(RepositorySessionFinishDelegate inner,
+ ExecutorService executor) {
+ this.executor = executor;
+ this.inner = inner;
+ }
+
+ @Override
+ public void onFinishSucceeded(final RepositorySession session,
+ final RepositorySessionBundle bundle) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ inner.onFinishSucceeded(session, bundle);
+ }
+ });
+ }
+
+ @Override
+ public void onFinishFailed(final Exception ex) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ inner.onFinishFailed(ex);
+ }
+ });
+ }
+
+ @Override
+ public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService newExecutor) {
+ if (newExecutor == executor) {
+ return this;
+ }
+ throw new IllegalArgumentException("Can't re-defer this delegate.");
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionStoreDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionStoreDelegate.java
new file mode 100644
index 000000000..2f659c733
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/DeferredRepositorySessionStoreDelegate.java
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import java.util.concurrent.ExecutorService;
+
+public class DeferredRepositorySessionStoreDelegate implements
+ RepositorySessionStoreDelegate {
+ protected final RepositorySessionStoreDelegate inner;
+ protected final ExecutorService executor;
+
+ public DeferredRepositorySessionStoreDelegate(
+ RepositorySessionStoreDelegate inner, ExecutorService executor) {
+ this.inner = inner;
+ this.executor = executor;
+ }
+
+ @Override
+ public void onRecordStoreSucceeded(final String guid) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ inner.onRecordStoreSucceeded(guid);
+ }
+ });
+ }
+
+ @Override
+ public void onRecordStoreFailed(final Exception ex, final String guid) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ inner.onRecordStoreFailed(ex, guid);
+ }
+ });
+ }
+
+ @Override
+ public RepositorySessionStoreDelegate deferredStoreDelegate(ExecutorService newExecutor) {
+ if (newExecutor == executor) {
+ return this;
+ }
+ throw new IllegalArgumentException("Can't re-defer this delegate.");
+ }
+
+ @Override
+ public void onStoreCompleted(final long storeEnd) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ inner.onStoreCompleted(storeEnd);
+ }
+ });
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionBeginDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionBeginDelegate.java
new file mode 100644
index 000000000..f5853647f
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionBeginDelegate.java
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import java.util.concurrent.ExecutorService;
+
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+
+/**
+ * One of these two methods is guaranteed to be called after session.begin() is
+ * invoked (possibly during the invocation). The callback will be invoked prior
+ * to any other RepositorySession callbacks.
+ *
+ * @author rnewman
+ *
+ */
+public interface RepositorySessionBeginDelegate {
+ public void onBeginFailed(Exception ex);
+ public void onBeginSucceeded(RepositorySession session);
+ public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCleanDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCleanDelegate.java
new file mode 100644
index 000000000..139c561a0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCleanDelegate.java
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import org.mozilla.gecko.sync.repositories.Repository;
+
+public interface RepositorySessionCleanDelegate {
+ public void onCleaned(Repository repo);
+ public void onCleanFailed(Repository repo, Exception ex);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCreationDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCreationDelegate.java
new file mode 100644
index 000000000..6ad4991c3
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionCreationDelegate.java
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+
+// Used to provide the sessionCallback and storeCallback
+// mechanism to repository instances.
+public interface RepositorySessionCreationDelegate {
+ public void onSessionCreateFailed(Exception ex);
+ public void onSessionCreated(RepositorySession session);
+ public RepositorySessionCreationDelegate deferredCreationDelegate();
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFetchRecordsDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFetchRecordsDelegate.java
new file mode 100644
index 000000000..589a093dc
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFetchRecordsDelegate.java
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import java.util.concurrent.ExecutorService;
+
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+public interface RepositorySessionFetchRecordsDelegate {
+ public void onFetchFailed(Exception ex, Record record);
+ public void onFetchedRecord(Record record);
+
+ /**
+ * Called when all records in this fetch have been returned.
+ *
+ * @param fetchEnd
+ * A millisecond-resolution timestamp indicating the *remote* timestamp
+ * at the end of the range of records. Usually this is the timestamp at
+ * which the request was received.
+ * E.g., the (normalized) value of the X-Weave-Timestamp header.
+ */
+ public void onFetchCompleted(final long fetchEnd);
+
+ public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFinishDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFinishDelegate.java
new file mode 100644
index 000000000..40296dd4f
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionFinishDelegate.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import java.util.concurrent.ExecutorService;
+
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+
+public interface RepositorySessionFinishDelegate {
+ public void onFinishFailed(Exception ex);
+ public void onFinishSucceeded(RepositorySession session, RepositorySessionBundle bundle);
+ public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService executor);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionGuidsSinceDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionGuidsSinceDelegate.java
new file mode 100644
index 000000000..4f82768f1
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionGuidsSinceDelegate.java
@@ -0,0 +1,10 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+public interface RepositorySessionGuidsSinceDelegate {
+ public void onGuidsSinceFailed(Exception ex);
+ public void onGuidsSinceSucceeded(String[] guids);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionStoreDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionStoreDelegate.java
new file mode 100644
index 000000000..01e44c3ae
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionStoreDelegate.java
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import java.util.concurrent.ExecutorService;
+
+/**
+ * These methods *must* be invoked asynchronously. Use deferredStoreDelegate if you
+ * need help doing this.
+ *
+ * @author rnewman
+ *
+ */
+public interface RepositorySessionStoreDelegate {
+ public void onRecordStoreFailed(Exception ex, String recordGuid);
+
+ // Called with a GUID when store has succeeded.
+ public void onRecordStoreSucceeded(String guid);
+ public void onStoreCompleted(long storeEnd);
+ public RepositorySessionStoreDelegate deferredStoreDelegate(ExecutorService executor);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionWipeDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionWipeDelegate.java
new file mode 100644
index 000000000..cc8830729
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/delegates/RepositorySessionWipeDelegate.java
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.delegates;
+
+import java.util.concurrent.ExecutorService;
+
+public interface RepositorySessionWipeDelegate {
+ public void onWipeFailed(Exception ex);
+ public void onWipeSucceeded();
+ public RepositorySessionWipeDelegate deferredWipeDelegate(ExecutorService executor);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecord.java
new file mode 100644
index 000000000..27b8e7151
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecord.java
@@ -0,0 +1,488 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.Map;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonArrayJSONException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.android.RepoUtils;
+
+/**
+ * Covers the fields used by all bookmark objects.
+ * @author rnewman
+ *
+ */
+public class BookmarkRecord extends Record {
+ public static final String PLACES_URI_PREFIX = "places:";
+
+ private static final String LOG_TAG = "BookmarkRecord";
+
+ public static final String COLLECTION_NAME = "bookmarks";
+ public static final long BOOKMARKS_TTL = -1; // Never ttl bookmarks.
+
+ public BookmarkRecord(String guid, String collection, long lastModified, boolean deleted) {
+ super(guid, collection, lastModified, deleted);
+ this.ttl = BOOKMARKS_TTL;
+ }
+ public BookmarkRecord(String guid, String collection, long lastModified) {
+ this(guid, collection, lastModified, false);
+ }
+ public BookmarkRecord(String guid, String collection) {
+ this(guid, collection, 0, false);
+ }
+ public BookmarkRecord(String guid) {
+ this(guid, COLLECTION_NAME, 0, false);
+ }
+ public BookmarkRecord() {
+ this(Utils.generateGuid(), COLLECTION_NAME, 0, false);
+ }
+
+ // Note: redundant accessors are evil. We're all grownups; let's just use
+ // public fields.
+ public String title;
+ public String bookmarkURI;
+ public String description;
+ public String keyword;
+ public String parentID;
+ public String parentName;
+ public long androidParentID;
+ public String type;
+ public long androidPosition;
+
+ public JSONArray children;
+ public JSONArray tags;
+
+ @Override
+ public String toString() {
+ return "#<Bookmark " + guid + " (" + androidID + "), parent " +
+ parentID + "/" + androidParentID + "/" + parentName + ">";
+ }
+
+ // Oh God, this is terribly thread-unsafe. These record objects should be immutable.
+ @SuppressWarnings("unchecked")
+ protected JSONArray copyChildren() {
+ if (this.children == null) {
+ return null;
+ }
+ JSONArray children = new JSONArray();
+ children.addAll(this.children);
+ return children;
+ }
+
+ @SuppressWarnings("unchecked")
+ protected JSONArray copyTags() {
+ if (this.tags == null) {
+ return null;
+ }
+ JSONArray tags = new JSONArray();
+ tags.addAll(this.tags);
+ return tags;
+ }
+
+ @Override
+ public Record copyWithIDs(String guid, long androidID) {
+ BookmarkRecord out = new BookmarkRecord(guid, this.collection, this.lastModified, this.deleted);
+ out.androidID = androidID;
+ out.sortIndex = this.sortIndex;
+ out.ttl = this.ttl;
+
+ // Copy BookmarkRecord fields.
+ out.title = this.title;
+ out.bookmarkURI = this.bookmarkURI;
+ out.description = this.description;
+ out.keyword = this.keyword;
+ out.parentID = this.parentID;
+ out.parentName = this.parentName;
+ out.androidParentID = this.androidParentID;
+ out.type = this.type;
+ out.androidPosition = this.androidPosition;
+
+ out.children = this.copyChildren();
+ out.tags = this.copyTags();
+
+ return out;
+ }
+
+ public boolean isBookmark() {
+ if (type == null) {
+ return false;
+ }
+ return type.equals("bookmark");
+ }
+
+ public boolean isFolder() {
+ if (type == null) {
+ return false;
+ }
+ return type.equals("folder");
+ }
+
+ public boolean isLivemark() {
+ if (type == null) {
+ return false;
+ }
+ return type.equals("livemark");
+ }
+
+ public boolean isSeparator() {
+ if (type == null) {
+ return false;
+ }
+ return type.equals("separator");
+ }
+
+ public boolean isMicrosummary() {
+ if (type == null) {
+ return false;
+ }
+ return type.equals("microsummary");
+ }
+
+ public boolean isQuery() {
+ if (type == null) {
+ return false;
+ }
+ return type.equals("query");
+ }
+
+ /**
+ * Return true if this record should have the Sync fields
+ * of a bookmark, microsummary, or query.
+ */
+ private boolean isBookmarkIsh() {
+ if (type == null) {
+ return false;
+ }
+ return type.equals("bookmark") ||
+ type.equals("microsummary") ||
+ type.equals("query");
+ }
+
+ @Override
+ protected void initFromPayload(ExtendedJSONObject payload) {
+ this.type = payload.getString("type");
+ this.title = payload.getString("title");
+ this.description = payload.getString("description");
+ this.parentID = payload.getString("parentid");
+ this.parentName = payload.getString("parentName");
+
+ if (isFolder()) {
+ try {
+ this.children = payload.getArray("children");
+ } catch (NonArrayJSONException e) {
+ Logger.error(LOG_TAG, "Got non-array children in bookmark record " + this.guid, e);
+ // Let's see if we can recover later by using the parentid pointers.
+ this.children = new JSONArray();
+ }
+ return;
+ }
+
+ final String bmkUri = payload.getString("bmkUri");
+
+ // bookmark, microsummary, query.
+ if (isBookmarkIsh()) {
+ this.keyword = payload.getString("keyword");
+ try {
+ this.tags = payload.getArray("tags");
+ } catch (NonArrayJSONException e) {
+ Logger.warn(LOG_TAG, "Got non-array tags in bookmark record " + this.guid, e);
+ this.tags = new JSONArray();
+ }
+ }
+
+ if (isBookmark()) {
+ this.bookmarkURI = bmkUri;
+ return;
+ }
+
+ if (isLivemark()) {
+ String siteUri = payload.getString("siteUri");
+ String feedUri = payload.getString("feedUri");
+ this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri,
+ "siteUri", siteUri,
+ "feedUri", feedUri);
+ return;
+ }
+ if (isQuery()) {
+ String queryId = payload.getString("queryId");
+ String folderName = payload.getString("folderName");
+ this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri,
+ "queryId", queryId,
+ "folderName", folderName);
+ return;
+ }
+ if (isMicrosummary()) {
+ String generatorUri = payload.getString("generatorUri");
+ String staticTitle = payload.getString("staticTitle");
+ this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri,
+ "generatorUri", generatorUri,
+ "staticTitle", staticTitle);
+ return;
+ }
+ if (isSeparator()) {
+ Object p = payload.get("pos");
+ if (p instanceof Long) {
+ this.androidPosition = (Long) p;
+ } else if (p instanceof String) {
+ try {
+ this.androidPosition = Long.parseLong((String) p, 10);
+ } catch (NumberFormatException e) {
+ return;
+ }
+ } else {
+ Logger.warn(LOG_TAG, "Unsupported position value " + p);
+ return;
+ }
+ String pos = String.valueOf(this.androidPosition);
+ this.bookmarkURI = encodeUnsupportedTypeURI(null, "pos", pos, null, null);
+ return;
+ }
+ }
+
+ @Override
+ protected void populatePayload(ExtendedJSONObject payload) {
+ putPayload(payload, "type", this.type);
+ putPayload(payload, "title", this.title);
+ putPayload(payload, "description", this.description);
+ putPayload(payload, "parentid", this.parentID);
+ putPayload(payload, "parentName", this.parentName);
+ putPayload(payload, "keyword", this.keyword);
+
+ if (isFolder()) {
+ payload.put("children", this.children);
+ return;
+ }
+
+ // bookmark, microsummary, query.
+ if (isBookmarkIsh()) {
+ if (isBookmark()) {
+ payload.put("bmkUri", bookmarkURI);
+ }
+
+ if (isQuery()) {
+ Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI);
+ putPayload(payload, "queryId", parts.get("queryId"), true);
+ putPayload(payload, "folderName", parts.get("folderName"), true);
+ putPayload(payload, "bmkUri", parts.get("uri"));
+ return;
+ }
+
+ if (this.tags != null) {
+ payload.put("tags", this.tags);
+ }
+
+ putPayload(payload, "keyword", this.keyword);
+ return;
+ }
+
+ if (isLivemark()) {
+ Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI);
+ putPayload(payload, "siteUri", parts.get("siteUri"));
+ putPayload(payload, "feedUri", parts.get("feedUri"));
+ return;
+ }
+ if (isMicrosummary()) {
+ Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI);
+ putPayload(payload, "generatorUri", parts.get("generatorUri"));
+ putPayload(payload, "staticTitle", parts.get("staticTitle"));
+ return;
+ }
+ if (isSeparator()) {
+ Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI);
+ String pos = parts.get("pos");
+ if (pos == null) {
+ return;
+ }
+ try {
+ payload.put("pos", Long.parseLong(pos, 10));
+ } catch (NumberFormatException e) {
+ return;
+ }
+ return;
+ }
+ }
+
+ private void trace(String s) {
+ Logger.trace(LOG_TAG, s);
+ }
+
+ @Override
+ public boolean equalPayloads(Object o) {
+ trace("Calling BookmarkRecord.equalPayloads.");
+ if (!(o instanceof BookmarkRecord)) {
+ return false;
+ }
+
+ BookmarkRecord other = (BookmarkRecord) o;
+ if (!super.equalPayloads(other)) {
+ return false;
+ }
+
+ if (!RepoUtils.stringsEqual(this.type, other.type)) {
+ return false;
+ }
+
+ // Check children.
+ if (isFolder() && (this.children != other.children)) {
+ trace("BookmarkRecord.equals: this folder: " + this.title + ", " + this.guid);
+ trace("BookmarkRecord.equals: other: " + other.title + ", " + other.guid);
+ if (this.children == null &&
+ other.children != null) {
+ trace("Records differ: one children array is null.");
+ return false;
+ }
+ if (this.children != null &&
+ other.children == null) {
+ trace("Records differ: one children array is null.");
+ return false;
+ }
+ if (this.children.size() != other.children.size()) {
+ trace("Records differ: children arrays differ in size (" +
+ this.children.size() + " vs. " + other.children.size() + ").");
+ return false;
+ }
+
+ for (int i = 0; i < this.children.size(); i++) {
+ String child = (String) this.children.get(i);
+ if (!other.children.contains(child)) {
+ trace("Records differ: child " + child + " not found.");
+ return false;
+ }
+ }
+ }
+
+ trace("Checking strings.");
+ return RepoUtils.stringsEqual(this.title, other.title)
+ && RepoUtils.stringsEqual(this.bookmarkURI, other.bookmarkURI)
+ && RepoUtils.stringsEqual(this.parentID, other.parentID)
+ && RepoUtils.stringsEqual(this.parentName, other.parentName)
+ && RepoUtils.stringsEqual(this.description, other.description)
+ && RepoUtils.stringsEqual(this.keyword, other.keyword)
+ && jsonArrayStringsEqual(this.tags, other.tags);
+ }
+
+ // TODO: two records can be congruent if their child lists are different.
+ @Override
+ public boolean congruentWith(Object o) {
+ return this.equalPayloads(o) &&
+ super.congruentWith(o);
+ }
+
+ // Converts two JSONArrays to strings and checks if they are the same.
+ // This is only useful for stuff like tags where we aren't actually
+ // touching the data there (and therefore ordering won't change)
+ private boolean jsonArrayStringsEqual(JSONArray a, JSONArray b) {
+ // Check for nulls
+ if (a == b) return true;
+ if (a == null && b != null) return false;
+ if (a != null && b == null) return false;
+ return RepoUtils.stringsEqual(a.toJSONString(), b.toJSONString());
+ }
+
+ /**
+ * URL-encode the provided string. If the input is null,
+ * the empty string is returned.
+ *
+ * @param in the string to encode.
+ * @return a URL-encoded version of the input.
+ */
+ protected static String encode(String in) {
+ if (in == null) {
+ return "";
+ }
+ try {
+ return URLEncoder.encode(in, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ // Will never occur.
+ return null;
+ }
+ }
+
+ /**
+ * Take the provided URI and two parameters, constructing a URI like
+ *
+ * places:uri=$uri&p1=$p1&p2=$p2
+ *
+ * null values in either parameter or value result in the parameter being omitted.
+ */
+ protected static String encodeUnsupportedTypeURI(String originalURI, String p1, String v1, String p2, String v2) {
+ StringBuilder b = new StringBuilder(PLACES_URI_PREFIX);
+ boolean previous = false;
+ if (originalURI != null) {
+ b.append("uri=");
+ b.append(encode(originalURI));
+ previous = true;
+ }
+ if (p1 != null && v1 != null) {
+ if (previous) {
+ b.append("&");
+ }
+ b.append(p1);
+ b.append("=");
+ b.append(encode(v1));
+ previous = true;
+ }
+ if (p2 != null && v2 != null) {
+ if (previous) {
+ b.append("&");
+ }
+ b.append(p2);
+ b.append("=");
+ b.append(encode(v2));
+ previous = true;
+ }
+ return b.toString();
+ }
+}
+
+
+/*
+// Bookmark:
+{cleartext:
+ {id: "l7p2xqOTMMXw",
+ type: "bookmark",
+ title: "Your Flight Status",
+ parentName: "mobile",
+ bmkUri: "http: //www.flightstats.com/go/Mobile/flightStatusByFlightProcess.do;jsessionid=13A6C8DCC9592AF141A43349040262CE.web3: 8009?utm_medium=cpc&utm_campaign=co-op&utm_source=airlineInformationAndStatus&id=212492593",
+ tags: [],
+ keyword: null,
+ description: null,
+ loadInSidebar: false,
+ parentid: "mobile"},
+ data: {payload: {ciphertext: null},
+ id: "l7p2xqOTMMXw",
+ sortindex: 107},
+ collection: "bookmarks"}
+
+// Folder:
+{cleartext:
+ {id: "mobile",
+ type: "folder",
+ parentName: "",
+ title: "mobile",
+ description: null,
+ children: ["1ROdlTuIoddD", "3Z_bMIHPSZQ8", "4mSDUuOo2iVB", "8aEdE9IIrJVr",
+ "9DzPTmkkZRDb", "Qwwb99HtVKsD", "s8tM36aGPKbq", "JMTi61hOO3JV",
+ "JQUDk0wSvYip", "LmVH-J1r3HLz", "NhgQlC5ykYGW", "OVanevUUaqO2",
+ "OtQVX0PMiWQj", "_GP5cF595iie", "fkRssjXSZDL3", "k7K_NwIA1Ya0",
+ "raox_QGzvqh1", "vXYL-xHjK06k", "QKHKUN6Dm-xv", "pmN2dYWT2MJ_",
+ "EVeO_J1SQiwL", "7N-qkepS7bec", "NIGa3ha-HVOE", "2Phv1I25wbuH",
+ "TTSIAH1fV0VE", "WOmZ8PfH39Da", "gDTXNg4m1AJZ", "ayI30OZslHbO",
+ "zSEs4O3n6CzQ", "oWTDR0gO2aWf", "wWHUoFaInXi9", "F7QTuVJDpsTM",
+ "FIboggegplk-", "G4HWrT5nfRYS", "MHA7y9bupDdv", "T_Ldzmj0Ttte",
+ "U9eYu3SxsE_U", "bk463Kl9IO_m", "brUfrqJjFNSR", "ccpawfWsD-bY",
+ "l7p2xqOTMMXw", "o-nSDKtXYln7"],
+ parentid: "places"},
+ data: {payload: {ciphertext: null},
+ id: "mobile",
+ sortindex: 1000000},
+ collection: "bookmarks"}
+*/
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecordFactory.java
new file mode 100644
index 000000000..edf7b288c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/BookmarkRecordFactory.java
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+
+/**
+ * Turns CryptoRecords into BookmarkRecords.
+ *
+ * @author rnewman
+ *
+ */
+public class BookmarkRecordFactory extends RecordFactory {
+
+ @Override
+ public Record createRecord(Record record) {
+ BookmarkRecord r = new BookmarkRecord();
+ r.initFromEnvelope((CryptoRecord) record);
+ return r;
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecord.java
new file mode 100644
index 000000000..0c513a4a0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecord.java
@@ -0,0 +1,231 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonArrayJSONException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.android.RepoUtils;
+
+public class ClientRecord extends Record {
+ private static final String LOG_TAG = "ClientRecord";
+
+ public static final String CLIENT_TYPE = "mobile";
+ public static final String COLLECTION_NAME = "clients";
+ public static final long CLIENTS_TTL = 21 * 24 * 60 * 60; // 21 days in seconds.
+ public static final String DEFAULT_CLIENT_NAME = "Default Name";
+
+ public static final String PROTOCOL_LEGACY_SYNC = "1.1";
+ public static final String PROTOCOL_FXA_SYNC = "1.5";
+
+ /**
+ * Each of these fields is 'owned' by the client it represents. For example,
+ * the "version" field is the Firefox version of that client; some time after
+ * that client upgrades, it'll upload a new record with its new version.
+ *
+ * The only exception is for commands. When a command is sent to a client, the
+ * sender will download its current record, append the command to the
+ * "commands" array, and reupload the record. After processing, the recipient
+ * will reupload its record with an empty commands array.
+ *
+ * Note that the version, then, will remain the version of the recipient, as
+ * with the other descriptive fields.
+ */
+ public String name = ClientRecord.DEFAULT_CLIENT_NAME;
+ public String type = ClientRecord.CLIENT_TYPE;
+ public String version = null; // Free-form string, optional.
+ public JSONArray commands;
+ public JSONArray protocols;
+
+ // Optional fields.
+ // See <https://github.com/mozilla-services/docs/blob/master/source/sync/objectformats.rst#user-content-clients>
+ // for full formats.
+ // If a value isn't known, the field is omitted.
+ public String formfactor; // "phone", "largetablet", "smalltablet", "desktop", "laptop", "tv".
+ public String os; // One of "Android", "Darwin", "WINNT", "Linux", "iOS", "Firefox OS".
+ public String application; // Display name, E.g., "Firefox Beta"
+ public String appPackage; // E.g., "org.mozilla.firefox_beta"
+ public String device; // E.g., "HTC One"
+ public String fxaDeviceId; // E.g., "525b624eaaf1e40d21ec8997c3116ad8"
+
+ public ClientRecord(String guid, String collection, long lastModified, boolean deleted) {
+ super(guid, collection, lastModified, deleted);
+ this.ttl = CLIENTS_TTL;
+ }
+
+ public ClientRecord(String guid, String collection, long lastModified) {
+ this(guid, collection, lastModified, false);
+ }
+
+ public ClientRecord(String guid, String collection) {
+ this(guid, collection, 0, false);
+ }
+
+ public ClientRecord(String guid) {
+ this(guid, COLLECTION_NAME, 0, false);
+ }
+
+ public ClientRecord() {
+ this(Utils.generateGuid(), COLLECTION_NAME, 0, false);
+ }
+
+ @Override
+ protected void initFromPayload(ExtendedJSONObject payload) {
+ this.name = (String) payload.get("name");
+ this.type = (String) payload.get("type");
+ try {
+ this.version = (String) payload.get("version");
+ } catch (Exception e) {
+ // Oh well.
+ }
+
+ try {
+ commands = payload.getArray("commands");
+ } catch (NonArrayJSONException e) {
+ Logger.debug(LOG_TAG, "Got non-array commands in client record " + guid, e);
+ commands = null;
+ }
+
+ try {
+ protocols = payload.getArray("protocols");
+ } catch (NonArrayJSONException e) {
+ Logger.debug(LOG_TAG, "Got non-array protocols in client record " + guid, e);
+ protocols = null;
+ }
+
+ if (payload.containsKey("formfactor")) {
+ this.formfactor = payload.getString("formfactor");
+ }
+
+ if (payload.containsKey("os")) {
+ this.os = payload.getString("os");
+ }
+
+ if (payload.containsKey("application")) {
+ this.application = payload.getString("application");
+ }
+
+ if (payload.containsKey("appPackage")) {
+ this.appPackage = payload.getString("appPackage");
+ }
+
+ if (payload.containsKey("device")) {
+ this.device = payload.getString("device");
+ }
+
+ if (payload.containsKey("fxaDeviceId")) {
+ this.fxaDeviceId = payload.getString("fxaDeviceId");
+ }
+ }
+
+ @Override
+ protected void populatePayload(ExtendedJSONObject payload) {
+ putPayload(payload, "id", this.guid);
+ putPayload(payload, "name", this.name);
+ putPayload(payload, "type", this.type);
+ putPayload(payload, "version", this.version);
+
+ if (this.commands != null) {
+ payload.put("commands", this.commands);
+ }
+
+ if (this.protocols != null) {
+ payload.put("protocols", this.protocols);
+ }
+
+ if (this.formfactor != null) {
+ payload.put("formfactor", this.formfactor);
+ }
+
+ if (this.os != null) {
+ payload.put("os", this.os);
+ }
+
+ if (this.application != null) {
+ payload.put("application", this.application);
+ }
+
+ if (this.appPackage != null) {
+ payload.put("appPackage", this.appPackage);
+ }
+
+ if (this.device != null) {
+ payload.put("device", this.device);
+ }
+
+ if (this.fxaDeviceId != null) {
+ payload.put("fxaDeviceId", this.fxaDeviceId);
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof ClientRecord) || !super.equals(o)) {
+ return false;
+ }
+
+ return this.equalPayloads(o);
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode();
+ }
+
+ @Override
+ public boolean equalPayloads(Object o) {
+ if (!(o instanceof ClientRecord) || !super.equalPayloads(o)) {
+ return false;
+ }
+
+ // Don't compare versions, protocols, or other optional fields, no matter how much we might want to.
+ // They're not required by the spec.
+ ClientRecord other = (ClientRecord) o;
+ if (!RepoUtils.stringsEqual(other.name, this.name) ||
+ !RepoUtils.stringsEqual(other.type, this.type)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public Record copyWithIDs(String guid, long androidID) {
+ ClientRecord out = new ClientRecord(guid, this.collection, this.lastModified, this.deleted);
+ out.androidID = androidID;
+ out.sortIndex = this.sortIndex;
+ out.ttl = this.ttl;
+
+ out.name = this.name;
+ out.type = this.type;
+ out.version = this.version;
+ out.protocols = this.protocols;
+
+ out.formfactor = this.formfactor;
+ out.os = this.os;
+ out.application = this.application;
+ out.appPackage = this.appPackage;
+ out.device = this.device;
+ out.fxaDeviceId = this.fxaDeviceId;
+
+ return out;
+ }
+
+/*
+Example record:
+
+{id:"relf31w7B4F1",
+ name:"marina_mac",
+ type:"mobile"
+ commands:[{"args":["bookmarks"],"command":"wipeEngine"},
+ {"args":["forms"],"command":"wipeEngine"},
+ {"args":["history"],"command":"wipeEngine"},
+ {"args":["passwords"],"command":"wipeEngine"},
+ {"args":["prefs"],"command":"wipeEngine"},
+ {"args":["tabs"],"command":"wipeEngine"},
+ {"args":["addons"],"command":"wipeEngine"}]}
+*/
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecordFactory.java
new file mode 100644
index 000000000..897d2859c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/ClientRecordFactory.java
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+
+public class ClientRecordFactory extends RecordFactory {
+ @Override
+ public Record createRecord(Record record) {
+ ClientRecord r = new ClientRecord();
+ r.initFromEnvelope((CryptoRecord) record);
+ return r;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/FormHistoryRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/FormHistoryRecord.java
new file mode 100644
index 000000000..e7ca70cb4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/FormHistoryRecord.java
@@ -0,0 +1,139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.android.RepoUtils;
+
+/**
+ * A FormHistoryRecord represents a saved form element.
+ *
+ * I map a <code>fieldName</code> string to a <code>value</code> string.
+ *
+ * @see "<a href='http://dxr.mozilla.org/services-central/source/services-central/services/sync/modules/engines/forms.js'>http://dxr.mozilla.org/services-central/source/services-central/services/sync/modules/engines/forms.js</a>."
+ */
+public class FormHistoryRecord extends Record {
+ private static final String LOG_TAG = "FormHistoryRecord";
+
+ public static final String COLLECTION_NAME = "forms";
+ private static final String PAYLOAD_NAME = "name";
+ private static final String PAYLOAD_VALUE = "value";
+ public static final long FORMS_TTL = 3 * 365 * 24 * 60 * 60; // Three years in seconds.
+
+ /**
+ * The name of the saved form field.
+ */
+ public String fieldName;
+
+ /**
+ * The value of the saved form field.
+ */
+ public String fieldValue;
+
+ public FormHistoryRecord(String guid, String collection, long lastModified, boolean deleted) {
+ super(guid, collection, lastModified, deleted);
+ this.ttl = FORMS_TTL;
+ }
+
+ public FormHistoryRecord(String guid, String collection, long lastModified) {
+ this(guid, collection, lastModified, false);
+ }
+
+ public FormHistoryRecord(String guid, String collection) {
+ this(guid, collection, 0, false);
+ }
+
+ public FormHistoryRecord(String guid) {
+ this(guid, COLLECTION_NAME, 0, false);
+ }
+
+ public FormHistoryRecord() {
+ this(Utils.generateGuid(), COLLECTION_NAME, 0, false);
+ }
+
+ @Override
+ public Record copyWithIDs(String guid, long androidID) {
+ FormHistoryRecord out = new FormHistoryRecord(guid, this.collection, this.lastModified, this.deleted);
+ out.androidID = androidID;
+ out.sortIndex = this.sortIndex;
+
+ // Copy FormHistoryRecord fields.
+ out.fieldName = this.fieldName;
+ out.fieldValue = this.fieldValue;
+
+ return out;
+ }
+
+ @Override
+ public void populatePayload(ExtendedJSONObject payload) {
+ putPayload(payload, PAYLOAD_NAME, this.fieldName);
+ putPayload(payload, PAYLOAD_VALUE, this.fieldValue);
+ }
+
+ @Override
+ public void initFromPayload(ExtendedJSONObject payload) {
+ this.fieldName = payload.getString(PAYLOAD_NAME);
+ this.fieldValue = payload.getString(PAYLOAD_VALUE);
+ }
+
+ /**
+ * We consider two form history records to be congruent if they represent the
+ * same form element regardless of times used.
+ */
+ @Override
+ public boolean congruentWith(Object o) {
+ if (!(o instanceof FormHistoryRecord)) {
+ return false;
+ }
+ FormHistoryRecord other = (FormHistoryRecord) o;
+ if (!super.congruentWith(other)) {
+ return false;
+ }
+ return RepoUtils.stringsEqual(this.fieldName, other.fieldName) &&
+ RepoUtils.stringsEqual(this.fieldValue, other.fieldValue);
+ }
+
+ @Override
+ public boolean equalPayloads(Object o) {
+ if (!(o instanceof FormHistoryRecord)) {
+ Logger.debug(LOG_TAG, "Not a FormHistoryRecord: " + o.getClass());
+ return false;
+ }
+ FormHistoryRecord other = (FormHistoryRecord) o;
+ if (!super.equalPayloads(other)) {
+ Logger.debug(LOG_TAG, "super.equalPayloads returned false.");
+ return false;
+ }
+
+ if (this.deleted) {
+ // FormHistoryRecords are equal if they are both deleted (which
+ // they are, since super.equalPayloads is true) and have the
+ // same GUID.
+ if (other.deleted) {
+ return RepoUtils.stringsEqual(this.guid, other.guid);
+ }
+ return false;
+ }
+
+ return RepoUtils.stringsEqual(this.fieldName, other.fieldName) &&
+ RepoUtils.stringsEqual(this.fieldValue, other.fieldValue);
+ }
+
+ public FormHistoryRecord log(String logTag) {
+ try {
+ Logger.debug(logTag, "Returning form history record " + guid + " (" + androidID + ")");
+ Logger.debug(logTag, "> Last modified: " + lastModified);
+ if (Logger.LOG_PERSONAL_INFORMATION) {
+ Logger.pii(logTag, "> Field name: " + fieldName);
+ Logger.pii(logTag, "> Field value: " + fieldValue);
+ }
+ } catch (Exception e) {
+ Logger.debug(logTag, "Exception logging form history record " + this, e);
+ }
+ return this;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecord.java
new file mode 100644
index 000000000..94eae13a7
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecord.java
@@ -0,0 +1,217 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import java.util.HashMap;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonArrayJSONException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.android.RepoUtils;
+
+/**
+ * Visits are in microsecond precision.
+ *
+ * @author rnewman
+ *
+ */
+public class HistoryRecord extends Record {
+ private static final String LOG_TAG = "HistoryRecord";
+
+ public static final String COLLECTION_NAME = "history";
+ public static final long HISTORY_TTL = 60 * 24 * 60 * 60; // 60 days in seconds.
+
+ public HistoryRecord(String guid, String collection, long lastModified, boolean deleted) {
+ super(guid, collection, lastModified, deleted);
+ this.ttl = HISTORY_TTL;
+ }
+ public HistoryRecord(String guid, String collection, long lastModified) {
+ this(guid, collection, lastModified, false);
+ }
+ public HistoryRecord(String guid, String collection) {
+ this(guid, collection, 0, false);
+ }
+ public HistoryRecord(String guid) {
+ this(guid, COLLECTION_NAME, 0, false);
+ }
+ public HistoryRecord() {
+ this(Utils.generateGuid(), COLLECTION_NAME, 0, false);
+ }
+
+ public String title;
+ public String histURI;
+ public JSONArray visits;
+ public long fennecDateVisited;
+ public long fennecVisitCount;
+
+ @SuppressWarnings("unchecked")
+ private JSONArray copyVisits() {
+ if (this.visits == null) {
+ return null;
+ }
+ JSONArray out = new JSONArray();
+ out.addAll(this.visits);
+ return out;
+ }
+
+ @Override
+ public Record copyWithIDs(String guid, long androidID) {
+ HistoryRecord out = new HistoryRecord(guid, this.collection, this.lastModified, this.deleted);
+ out.androidID = androidID;
+ out.sortIndex = this.sortIndex;
+ out.ttl = this.ttl;
+
+ // Copy HistoryRecord fields.
+ out.title = this.title;
+ out.histURI = this.histURI;
+ out.fennecDateVisited = this.fennecDateVisited;
+ out.fennecVisitCount = this.fennecVisitCount;
+ out.visits = this.copyVisits();
+
+ return out;
+ }
+
+ @Override
+ protected void populatePayload(ExtendedJSONObject payload) {
+ putPayload(payload, "id", this.guid);
+ putPayload(payload, "title", this.title);
+ putPayload(payload, "histUri", this.histURI); // TODO: encoding?
+ payload.put("visits", this.visits);
+ }
+
+ @Override
+ protected void initFromPayload(ExtendedJSONObject payload) {
+ this.histURI = (String) payload.get("histUri");
+ this.title = (String) payload.get("title");
+ try {
+ this.visits = payload.getArray("visits");
+ } catch (NonArrayJSONException e) {
+ Logger.error(LOG_TAG, "Got non-array visits in history record " + this.guid, e);
+ this.visits = new JSONArray();
+ }
+ }
+
+ /**
+ * We consider two history records to be congruent if they represent the
+ * same history record regardless of visits. Titles are allowed to differ,
+ * but the URI must be the same.
+ */
+ @Override
+ public boolean congruentWith(Object o) {
+ if (!(o instanceof HistoryRecord)) {
+ return false;
+ }
+ HistoryRecord other = (HistoryRecord) o;
+ if (!super.congruentWith(other)) {
+ return false;
+ }
+ return RepoUtils.stringsEqual(this.histURI, other.histURI);
+ }
+
+ @Override
+ public boolean equalPayloads(Object o) {
+ if (!(o instanceof HistoryRecord)) {
+ Logger.debug(LOG_TAG, "Not a HistoryRecord: " + o.getClass());
+ return false;
+ }
+ HistoryRecord other = (HistoryRecord) o;
+ if (!super.equalPayloads(other)) {
+ Logger.debug(LOG_TAG, "super.equalPayloads returned false.");
+ return false;
+ }
+ return RepoUtils.stringsEqual(this.title, other.title) &&
+ RepoUtils.stringsEqual(this.histURI, other.histURI) &&
+ checkVisitsEquals(other);
+ }
+
+ @Override
+ public boolean equalAndroidIDs(Record other) {
+ return super.equalAndroidIDs(other) &&
+ this.equalFennecVisits(other);
+ }
+
+ private boolean equalFennecVisits(Record other) {
+ if (!(other instanceof HistoryRecord)) {
+ return false;
+ }
+ HistoryRecord h = (HistoryRecord) other;
+ return this.fennecDateVisited == h.fennecDateVisited &&
+ this.fennecVisitCount == h.fennecVisitCount;
+ }
+
+ private boolean checkVisitsEquals(HistoryRecord other) {
+ Logger.debug(LOG_TAG, "Checking visits.");
+ if (Logger.LOG_PERSONAL_INFORMATION) {
+ // Don't JSON-encode unless we're logging.
+ Logger.pii(LOG_TAG, ">> Mine: " + ((this.visits == null) ? "null" : this.visits.toJSONString()));
+ Logger.pii(LOG_TAG, ">> Theirs: " + ((other.visits == null) ? "null" : other.visits.toJSONString()));
+ }
+
+ // Handle nulls.
+ if (this.visits == other.visits) {
+ return true;
+ }
+
+ // Now they can't both be null.
+ int aSize = this.visits == null ? 0 : this.visits.size();
+ int bSize = other.visits == null ? 0 : other.visits.size();
+
+ if (aSize != bSize) {
+ return false;
+ }
+
+ // Now neither of them can be null.
+
+ // TODO: do this by maintaining visits as a sorted array.
+ HashMap<Long, Long> otherVisits = new HashMap<Long, Long>();
+ for (int i = 0; i < bSize; i++) {
+ JSONObject visit = (JSONObject) other.visits.get(i);
+ otherVisits.put((Long) visit.get("date"), (Long) visit.get("type"));
+ }
+
+ for (int i = 0; i < aSize; i++) {
+ JSONObject visit = (JSONObject) this.visits.get(i);
+ if (!otherVisits.containsKey(visit.get("date"))) {
+ return false;
+ }
+ Long otherDate = (Long) visit.get("date");
+ Long otherType = otherVisits.get(otherDate);
+ if (otherType == null) {
+ return false;
+ }
+ if (!otherType.equals((Long) visit.get("type"))) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+//
+// Example record (note microsecond resolution):
+//
+// {id:"--DUvUomABNq",
+// histUri:"https://bugzilla.mozilla.org/show_bug.cgi?id=697634",
+// title:"697634 \u2013 xpcshell test failures on 10.7",
+// visits:[{date:1320087601465600, type:2},
+// {date:1320084970724990, type:1},
+// {date:1320084847035717, type:1},
+// {date:1319764134412287, type:1},
+// {date:1319757917982518, type:1},
+// {date:1319751664627351, type:1},
+// {date:1319681421072326, type:1},
+// {date:1319681306455594, type:1},
+// {date:1319678117125234, type:1},
+// {date:1319677508862901, type:1}]
+// }
+//
+//"type" is a transition type:
+//
+//https://developer.mozilla.org/en/XPCOM_Interface_Reference/nsINavHistoryService#Transition_type_constants
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecordFactory.java
new file mode 100644
index 000000000..ac2c6a1dc
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/HistoryRecordFactory.java
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+
+/**
+ * Turns CryptoRecords into HistoryRecords.
+ *
+ * @author rnewman
+ *
+ */
+public class HistoryRecordFactory extends RecordFactory {
+
+ @Override
+ public Record createRecord(Record record) {
+ HistoryRecord r = new HistoryRecord();
+ r.initFromEnvelope((CryptoRecord) record);
+ return r;
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecord.java
new file mode 100644
index 000000000..b2de60f3c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecord.java
@@ -0,0 +1,205 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.android.RepoUtils;
+
+public class PasswordRecord extends Record {
+ private static final String LOG_TAG = "PasswordRecord";
+
+ public static final String COLLECTION_NAME = "passwords";
+ public static long PASSWORDS_TTL = -1; // Never expire passwords.
+
+ // Payload strings.
+ public static final String PAYLOAD_HOSTNAME = "hostname";
+ public static final String PAYLOAD_FORM_SUBMIT_URL = "formSubmitURL";
+ public static final String PAYLOAD_HTTP_REALM = "httpRealm";
+ public static final String PAYLOAD_USERNAME = "username";
+ public static final String PAYLOAD_PASSWORD = "password";
+ public static final String PAYLOAD_USERNAME_FIELD = "usernameField";
+ public static final String PAYLOAD_PASSWORD_FIELD = "passwordField";
+
+ public PasswordRecord(String guid, String collection, long lastModified, boolean deleted) {
+ super(guid, collection, lastModified, deleted);
+ this.ttl = PASSWORDS_TTL;
+ }
+ public PasswordRecord(String guid, String collection, long lastModified) {
+ this(guid, collection, lastModified, false);
+ }
+ public PasswordRecord(String guid, String collection) {
+ this(guid, collection, 0, false);
+ }
+ public PasswordRecord(String guid) {
+ this(guid, COLLECTION_NAME, 0, false);
+ }
+ public PasswordRecord() {
+ this(Utils.generateGuid(), COLLECTION_NAME, 0, false);
+ }
+
+ public String id;
+ public String hostname;
+ public String formSubmitURL;
+ public String httpRealm;
+ // TODO these are encrypted in the passwords content provider,
+ // need to figure out what we need to do here.
+ public String usernameField;
+ public String passwordField;
+ public String encryptedUsername;
+ public String encryptedPassword;
+ public String encType;
+
+ public long timeCreated;
+ public long timeLastUsed;
+ public long timePasswordChanged;
+ public long timesUsed;
+
+
+ @Override
+ public Record copyWithIDs(String guid, long androidID) {
+ PasswordRecord out = new PasswordRecord(guid, this.collection, this.lastModified, this.deleted);
+ out.androidID = androidID;
+ out.sortIndex = this.sortIndex;
+ out.ttl = this.ttl;
+
+ // Copy PasswordRecord fields.
+ out.id = this.id;
+ out.hostname = this.hostname;
+ out.formSubmitURL = this.formSubmitURL;
+ out.httpRealm = this.httpRealm;
+
+ out.usernameField = this.usernameField;
+ out.passwordField = this.passwordField;
+ out.encryptedUsername = this.encryptedUsername;
+ out.encryptedPassword = this.encryptedPassword;
+ out.encType = this.encType;
+
+ out.timeCreated = this.timeCreated;
+ out.timeLastUsed = this.timeLastUsed;
+ out.timePasswordChanged = this.timePasswordChanged;
+ out.timesUsed = this.timesUsed;
+
+ return out;
+ }
+
+ @Override
+ public void initFromPayload(ExtendedJSONObject payload) {
+ this.hostname = payload.getString(PAYLOAD_HOSTNAME);
+ this.formSubmitURL = payload.getString(PAYLOAD_FORM_SUBMIT_URL);
+ this.httpRealm = payload.getString(PAYLOAD_HTTP_REALM);
+ this.encryptedUsername = payload.getString(PAYLOAD_USERNAME);
+ this.encryptedPassword = payload.getString(PAYLOAD_PASSWORD);
+ this.usernameField = payload.getString(PAYLOAD_USERNAME_FIELD);
+ this.passwordField = payload.getString(PAYLOAD_PASSWORD_FIELD);
+ }
+
+ @Override
+ public void populatePayload(ExtendedJSONObject payload) {
+ putPayload(payload, PAYLOAD_HOSTNAME, this.hostname);
+ putPayload(payload, PAYLOAD_FORM_SUBMIT_URL, this.formSubmitURL);
+ putPayload(payload, PAYLOAD_HTTP_REALM, this.httpRealm);
+ putPayload(payload, PAYLOAD_USERNAME, this.encryptedUsername);
+ putPayload(payload, PAYLOAD_PASSWORD, this.encryptedPassword);
+ putPayload(payload, PAYLOAD_USERNAME_FIELD, this.usernameField);
+ putPayload(payload, PAYLOAD_PASSWORD_FIELD, this.passwordField);
+ }
+
+ @Override
+ public boolean congruentWith(Object o) {
+ if (!(o instanceof PasswordRecord)) {
+ return false;
+ }
+ PasswordRecord other = (PasswordRecord) o;
+ if (!super.congruentWith(other)) {
+ return false;
+ }
+ return RepoUtils.stringsEqual(this.hostname, other.hostname)
+ && RepoUtils.stringsEqual(this.formSubmitURL, other.formSubmitURL)
+ // Bug 738347 - SQLiteBridge does not check for nulls in ContentValues.
+ // && RepoUtils.stringsEqual(this.httpRealm, other.httpRealm)
+ // && RepoUtils.stringsEqual(this.encType, other.encType)
+ && RepoUtils.stringsEqual(this.usernameField, other.usernameField)
+ && RepoUtils.stringsEqual(this.passwordField, other.passwordField)
+ && RepoUtils.stringsEqual(this.encryptedUsername, other.encryptedUsername)
+ && RepoUtils.stringsEqual(this.encryptedPassword, other.encryptedPassword);
+ }
+
+ @Override
+ public boolean equalPayloads(Object o) {
+ if (!(o instanceof PasswordRecord)) {
+ return false;
+ }
+
+ PasswordRecord other = (PasswordRecord) o;
+ Logger.debug("PasswordRecord", "thisRecord:" + this.toString());
+ Logger.debug("PasswordRecord", "otherRecord:" + o.toString());
+
+ if (this.deleted) {
+ if (other.deleted) {
+ // Deleted records are equal if their guids match.
+ return RepoUtils.stringsEqual(this.guid, other.guid);
+ }
+ // One record is deleted, the other is not. Not equal.
+ return false;
+ }
+
+ if (!super.equalPayloads(other)) {
+ Logger.debug(LOG_TAG, "super.equalPayloads returned false.");
+ return false;
+ }
+
+ return RepoUtils.stringsEqual(this.hostname, other.hostname)
+ && RepoUtils.stringsEqual(this.formSubmitURL, other.formSubmitURL)
+ // Bug 738347 - SQLiteBridge does not check for nulls in ContentValues.
+ // && RepoUtils.stringsEqual(this.httpRealm, other.httpRealm)
+ // && RepoUtils.stringsEqual(this.encType, other.encType)
+ && RepoUtils.stringsEqual(this.usernameField, other.usernameField)
+ && RepoUtils.stringsEqual(this.passwordField, other.passwordField)
+ && RepoUtils.stringsEqual(this.encryptedUsername, other.encryptedUsername)
+ && RepoUtils.stringsEqual(this.encryptedPassword, other.encryptedPassword);
+ // Desktop sync never sets timeCreated so this isn't relevant for sync records.
+ }
+
+ @Override
+ public String toString() {
+ return "PasswordRecord {"
+ + "lastModified: " + this.lastModified + ", "
+ + "hostname null?: " + (this.hostname == null) + ", "
+ + "formSubmitURL null?: " + (this.formSubmitURL == null) + ", "
+ + "httpRealm null?: " + (this.httpRealm == null) + ", "
+ + "usernameField null?: " + (this.usernameField == null) + ", "
+ + "passwordField null?: " + (this.passwordField == null) + ", "
+ + "encryptedUsername null?: " + (this.encryptedUsername == null) + ", "
+ + "encryptedPassword null?: " + (this.encryptedPassword == null) + ", "
+ + "encType: " + this.encType + ", "
+ + "timeCreated: " + this.timeCreated + ", "
+ + "timeLastUsed: " + this.timeLastUsed + ", "
+ + "timePasswordChanged: " + this.timePasswordChanged + ", "
+ + "timesUsed: " + this.timesUsed;
+ }
+
+ /**
+ * A PasswordRecord is considered valid if it abides by the database
+ * constraints of the PasswordsProvider (moz_logins).
+ *
+ * See toolkit/components/passwordmgr/storage-mozStorage.js for the
+ * definitions:
+ *
+ * http://hg.mozilla.org/mozilla-central/file/00955d61cc94/toolkit/components/passwordmgr/storage-mozStorage.js#l98
+ */
+ public boolean isValid() {
+ if (this.deleted) {
+ return true;
+ }
+
+ return this.hostname != null &&
+ this.encryptedUsername != null &&
+ this.encryptedPassword != null &&
+ this.usernameField != null &&
+ this.passwordField != null;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecordFactory.java
new file mode 100644
index 000000000..fc7ef916d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/PasswordRecordFactory.java
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+import org.mozilla.gecko.sync.repositories.domain.PasswordRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+public class PasswordRecordFactory extends RecordFactory {
+ @Override
+ public Record createRecord(Record record) {
+ PasswordRecord r = new PasswordRecord();
+ r.initFromEnvelope((CryptoRecord) record);
+ return r;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/Record.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/Record.java
new file mode 100644
index 000000000..145704c1c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/Record.java
@@ -0,0 +1,308 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import java.io.UnsupportedEncodingException;
+
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+/**
+ * Record is the abstract base class for all entries that Sync processes:
+ * bookmarks, passwords, history, and such.
+ *
+ * A Record can be initialized from or serialized to a CryptoRecord for
+ * submission to an encrypted store.
+ *
+ * Records should be considered to be conventionally immutable: modifications
+ * should be completed before the new record object escapes its constructing
+ * scope. Note that this is a critically important part of equality. As Rich
+ * Hickey notes:
+ *
+ * … the only things you can really compare for equality are immutable things,
+ * because if you compare two things for equality that are mutable, and ever
+ * say true, and they're ever not the same thing, you are wrong. Or you will
+ * become wrong at some point in the future.
+ *
+ * Records have a layered definition of equality. Two records can be said to be
+ * "equal" if:
+ *
+ * * They have the same GUID and collection. Two crypto/keys records are in some
+ * way "the same".
+ * This is `equalIdentifiers`.
+ *
+ * * Their most significant fields are the same. That is to say, they share a
+ * GUID, a collection, deletion, and domain-specific fields. Two copies of
+ * crypto/keys, neither deleted, with the same encrypted data but different
+ * modified times and sortIndex are in a stronger way "the same".
+ * This is `equalPayloads`.
+ *
+ * * Their most significant fields are the same, and their local fields (e.g.,
+ * the androidID to which we have decided that this record maps) are congruent.
+ * A record with the same androidID, or one whose androidID has not been set,
+ * can be considered "the same".
+ * This concept can be extended by Record subclasses. The key point is that
+ * reconciling should be applied to the contents of these records. For example,
+ * two history records with the same URI and GUID, but different visit arrays,
+ * can be said to be congruent.
+ * This is `congruentWith`.
+ *
+ * * They are strictly identical. Every field that is persisted, including
+ * lastModified and androidID, is equal.
+ * This is `equals`.
+ *
+ * Different parts of the codebase have use for different layers of this
+ * comparison hierarchy. For instance, lastModified times change every time a
+ * record is stored; a store followed by a retrieval will return a Record that
+ * shares its most significant fields with the input, but has a later
+ * lastModified time and might not yet have values set for others. Reconciling
+ * will thus ignore the modification time of a record.
+ *
+ * @author rnewman
+ *
+ */
+public abstract class Record {
+
+ public String guid;
+ public String collection;
+ public long lastModified;
+ public boolean deleted;
+ public long androidID;
+ /**
+ * An integer indicating the relative importance of this item in the collection.
+ * <p>
+ * Default is 0.
+ */
+ public long sortIndex;
+ /**
+ * The number of seconds to keep this record. After that time this item will
+ * no longer be returned in response to any request, and it may be pruned from
+ * the database.
+ * <p>
+ * Negative values mean never forget this record.
+ * <p>
+ * Default is 1 year.
+ */
+ public long ttl;
+
+ public Record(String guid, String collection, long lastModified, boolean deleted) {
+ this.guid = guid;
+ this.collection = collection;
+ this.lastModified = lastModified;
+ this.deleted = deleted;
+ this.sortIndex = 0;
+ this.ttl = 365 * 24 * 60 * 60; // Seconds.
+ this.androidID = -1;
+ }
+
+ /**
+ * Return true iff the input is a Record and has the same
+ * collection and guid as this object.
+ */
+ public boolean equalIdentifiers(Object o) {
+ if (!(o instanceof Record)) {
+ return false;
+ }
+
+ Record other = (Record) o;
+ if (this.guid == null) {
+ if (other.guid != null) {
+ return false;
+ }
+ } else {
+ if (!this.guid.equals(other.guid)) {
+ return false;
+ }
+ }
+ if (this.collection == null) {
+ if (other.collection != null) {
+ return false;
+ }
+ } else {
+ if (!this.collection.equals(other.collection)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * @param o
+ * The object to which this object should be compared.
+ * @return
+ * true iff the input is a Record which is substantially the
+ * same as this object.
+ */
+ public boolean equalPayloads(Object o) {
+ if (!this.equalIdentifiers(o)) {
+ return false;
+ }
+ Record other = (Record) o;
+ return this.deleted == other.deleted;
+ }
+
+ /**
+ *
+ *
+ * @param o
+ * The object to which this object should be compared.
+ * @return
+ * true iff the input is a Record which is substantially the
+ * same as this object, considering the ability and desire to
+ * reconcile the two objects if possible.
+ */
+ public boolean congruentWith(Object o) {
+ if (!this.equalIdentifiers(o)) {
+ return false;
+ }
+ Record other = (Record) o;
+ return congruentAndroidIDs(other) &&
+ (this.deleted == other.deleted);
+ }
+
+ public boolean congruentAndroidIDs(Record other) {
+ // We treat -1 as "unset", and treat this as
+ // congruent with any other value.
+ if (this.androidID != -1 &&
+ other.androidID != -1 &&
+ this.androidID != other.androidID) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Return true iff the input is both equal in terms of payload,
+ * and also shares transient values such as timestamps.
+ */
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Record)) {
+ return false;
+ }
+
+ Record other = (Record) o;
+ return equalTimestamps(other) &&
+ equalSortIndices(other) &&
+ equalAndroidIDs(other) &&
+ equalPayloads(o);
+ }
+
+ public boolean equalAndroidIDs(Record other) {
+ return this.androidID == other.androidID;
+ }
+
+ public boolean equalSortIndices(Record other) {
+ return this.sortIndex == other.sortIndex;
+ }
+
+ public boolean equalTimestamps(Object o) {
+ if (!(o instanceof Record)) {
+ return false;
+ }
+ return ((Record) o).lastModified == this.lastModified;
+ }
+
+ protected abstract void populatePayload(ExtendedJSONObject payload);
+ protected abstract void initFromPayload(ExtendedJSONObject payload);
+
+ public void initFromEnvelope(CryptoRecord envelope) {
+ ExtendedJSONObject p = envelope.payload;
+ this.guid = envelope.guid;
+ checkGUIDs(p);
+
+ this.collection = envelope.collection;
+ this.lastModified = envelope.lastModified;
+
+ final Object del = p.get("deleted");
+ if (del instanceof Boolean) {
+ this.deleted = (Boolean) del;
+ } else {
+ this.initFromPayload(p);
+ }
+
+ }
+
+ public CryptoRecord getEnvelope() {
+ CryptoRecord rec = new CryptoRecord(this);
+ ExtendedJSONObject payload = new ExtendedJSONObject();
+ payload.put("id", this.guid);
+
+ if (this.deleted) {
+ payload.put("deleted", true);
+ } else {
+ populatePayload(payload);
+ }
+ rec.payload = payload;
+ return rec;
+ }
+
+ @SuppressWarnings("static-method")
+ public String toJSONString() {
+ throw new RuntimeException("Cannot JSONify non-CryptoRecord Records.");
+ }
+
+ public byte[] toJSONBytes() {
+ try {
+ return this.toJSONString().getBytes("UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ // Can't happen.
+ return null;
+ }
+ }
+
+ /**
+ * Utility for safely populating an output CryptoRecord.
+ *
+ * @param rec
+ * @param key
+ * @param value
+ */
+ @SuppressWarnings("static-method")
+ protected void putPayload(CryptoRecord rec, String key, String value) {
+ if (value == null) {
+ return;
+ }
+ rec.payload.put(key, value);
+ }
+
+ protected void putPayload(ExtendedJSONObject payload, String key, String value) {
+ this.putPayload(payload, key, value, false);
+ }
+
+ @SuppressWarnings("static-method")
+ protected void putPayload(ExtendedJSONObject payload, String key, String value, boolean excludeEmpty) {
+ if (value == null) {
+ return;
+ }
+ if (excludeEmpty && value.equals("")) {
+ return;
+ }
+ payload.put(key, value);
+ }
+
+ protected void checkGUIDs(ExtendedJSONObject payload) {
+ String payloadGUID = (String) payload.get("id");
+ if (this.guid == null ||
+ payloadGUID == null) {
+ String detailMessage = "Inconsistency: either envelope or payload GUID missing.";
+ throw new IllegalStateException(detailMessage);
+ }
+ if (!this.guid.equals(payloadGUID)) {
+ String detailMessage = "Inconsistency: record has envelope ID " + this.guid + ", payload ID " + payloadGUID;
+ throw new IllegalStateException(detailMessage);
+ }
+ }
+
+ /**
+ * Oh for persistent data structures.
+ *
+ * @param guid
+ * @param androidID
+ * @return
+ * An identical copy of this record with the provided two values.
+ */
+ public abstract Record copyWithIDs(String guid, long androidID);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/RecordParseException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/RecordParseException.java
new file mode 100644
index 000000000..0d8fe90b2
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/RecordParseException.java
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+
+public class RecordParseException extends Exception {
+ private static final long serialVersionUID = -5145494854722254491L;
+
+ public RecordParseException(String detailMessage) {
+ super(detailMessage);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecord.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecord.java
new file mode 100644
index 000000000..eb3a4f6d0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecord.java
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import java.util.ArrayList;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.db.Tab;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonArrayJSONException;
+import org.mozilla.gecko.sync.Utils;
+
+import android.content.ContentValues;
+
+/**
+ * Represents a client's collection of tabs.
+ *
+ * @author rnewman
+ *
+ */
+public class TabsRecord extends Record {
+ public static final String LOG_TAG = "TabsRecord";
+
+ public static final String COLLECTION_NAME = "tabs";
+ public static final long TABS_TTL = 7 * 24 * 60 * 60; // 7 days in seconds.
+
+ public TabsRecord(String guid, String collection, long lastModified, boolean deleted) {
+ super(guid, collection, lastModified, deleted);
+ this.ttl = TABS_TTL;
+ }
+ public TabsRecord(String guid, String collection, long lastModified) {
+ this(guid, collection, lastModified, false);
+ }
+ public TabsRecord(String guid, String collection) {
+ this(guid, collection, 0, false);
+ }
+ public TabsRecord(String guid) {
+ this(guid, COLLECTION_NAME, 0, false);
+ }
+ public TabsRecord() {
+ this(Utils.generateGuid(), COLLECTION_NAME, 0, false);
+ }
+
+ public String clientName;
+ public ArrayList<Tab> tabs;
+
+ @Override
+ public void initFromPayload(ExtendedJSONObject payload) {
+ clientName = (String) payload.get("clientName");
+ try {
+ tabs = tabsFrom(payload.getArray("tabs"));
+ } catch (NonArrayJSONException e) {
+ // Oh well.
+ tabs = new ArrayList<Tab>();
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ protected static JSONArray tabsToJSON(ArrayList<Tab> tabs) {
+ JSONArray out = new JSONArray();
+ for (Tab tab : tabs) {
+ out.add(tabToJSONObject(tab));
+ }
+ return out;
+ }
+
+ protected static ArrayList<Tab> tabsFrom(JSONArray in) {
+ ArrayList<Tab> tabs = new ArrayList<Tab>(in.size());
+ for (Object o : in) {
+ if (o instanceof JSONObject) {
+ try {
+ tabs.add(TabsRecord.tabFromJSONObject((JSONObject) o));
+ } catch (NonArrayJSONException e) {
+ Logger.warn(LOG_TAG, "urlHistory is not an array for this tab.", e);
+ }
+ }
+ }
+ return tabs;
+ }
+
+ @Override
+ public void populatePayload(ExtendedJSONObject payload) {
+ putPayload(payload, "id", this.guid);
+ putPayload(payload, "clientName", this.clientName);
+ payload.put("tabs", tabsToJSON(this.tabs));
+ }
+
+ @Override
+ public Record copyWithIDs(String guid, long androidID) {
+ TabsRecord out = new TabsRecord(guid, this.collection, this.lastModified, this.deleted);
+ out.androidID = androidID;
+ out.sortIndex = this.sortIndex;
+ out.ttl = this.ttl;
+
+ out.clientName = this.clientName;
+ out.tabs = new ArrayList<Tab>(this.tabs);
+
+ return out;
+ }
+
+ public ContentValues getClientsContentValues() {
+ ContentValues cv = new ContentValues();
+ cv.put(BrowserContract.Clients.GUID, this.guid);
+ cv.put(BrowserContract.Clients.NAME, this.clientName);
+ cv.put(BrowserContract.Clients.LAST_MODIFIED, this.lastModified);
+ return cv;
+ }
+
+ public ContentValues[] getTabsContentValues() {
+ int c = tabs.size();
+ ContentValues[] out = new ContentValues[c];
+ for (int i = 0; i < c; i++) {
+ out[i] = tabs.get(i).toContentValues(this.guid, i);
+ }
+ return out;
+ }
+
+ public static Tab tabFromJSONObject(JSONObject o) throws NonArrayJSONException {
+ ExtendedJSONObject obj = new ExtendedJSONObject(o);
+ String title = obj.getString("title");
+ String icon = obj.getString("icon");
+ JSONArray history = obj.getArray("urlHistory");
+
+ // Last used is inexplicably a string in seconds. Most of the time.
+ long lastUsed = 0;
+ Object lU = obj.get("lastUsed");
+ if (lU instanceof Number) {
+ lastUsed = ((Long) lU) * 1000L;
+ } else if (lU instanceof String) {
+ try {
+ lastUsed = Long.parseLong((String) lU, 10) * 1000L;
+ } catch (NumberFormatException e) {
+ Logger.debug(TabsRecord.LOG_TAG, "Invalid number format in lastUsed: " + lU);
+ }
+ }
+ return new Tab(title, icon, history, lastUsed);
+ }
+
+ @SuppressWarnings("unchecked")
+ public static JSONObject tabToJSONObject(Tab tab) {
+ JSONObject o = new JSONObject();
+ o.put("title", tab.title);
+ o.put("icon", tab.icon);
+ o.put("urlHistory", tab.history);
+ o.put("lastUsed", tab.lastUsed / 1000);
+ return o;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecordFactory.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecordFactory.java
new file mode 100644
index 000000000..9504434d8
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/TabsRecordFactory.java
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+
+public class TabsRecordFactory extends RecordFactory {
+ @Override
+ public Record createRecord(Record record) {
+ TabsRecord r = new TabsRecord();
+ r.initFromEnvelope((CryptoRecord) record);
+ return r;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/VersionConstants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/VersionConstants.java
new file mode 100644
index 000000000..2d3d4fd32
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/VersionConstants.java
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+public class VersionConstants {
+ public static final int BOOKMARKS_ENGINE_VERSION = 2;
+ public static final int CLIENTS_ENGINE_VERSION = 1;
+ public static final int FORMS_ENGINE_VERSION = 1;
+ public static final int HISTORY_ENGINE_VERSION = 1;
+ public static final int PASSWORDS_ENGINE_VERSION = 1;
+ public static final int TABS_ENGINE_VERSION = 1;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloader.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloader.java
new file mode 100644
index 000000000..5c3037e4d
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloader.java
@@ -0,0 +1,310 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.downloaders;
+
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.DelayedWorkTracker;
+import org.mozilla.gecko.sync.net.SyncResponse;
+import org.mozilla.gecko.sync.net.SyncStorageCollectionRequest;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.Server11Repository;
+import org.mozilla.gecko.sync.repositories.Server11RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Batching Downloader, which implements batching protocol as supported by Sync 1.5.
+ *
+ * Downloader's batching behaviour is configured via two parameters, obtained from the repository:
+ * - Per-batch limit, which specified how many records may be fetched in an individual GET request.
+ * - Total limit, which controls number of batch GET requests we will make.
+ *
+ *
+ * Batching is implemented via specifying a 'limit' GET parameter, and looking for an 'offset' token
+ * in the response. If offset token is present, this indicates that there are more records than what
+ * we've received so far, and we perform an additional fetch. Batching stops when either we hit a total
+ * limit, or offset token is no longer present (indicating that we're done).
+ *
+ * For unlimited repositories (such as passwords), both of these value will be -1. Downloader will not
+ * specify a limit parameter in this case, and the response will contain every record available and no
+ * offset token, thus fully completing in one go.
+ *
+ * In between batches, we maintain a Last-Modified timestamp, based off the value return in the header
+ * of the first response. Every response will have a Last-Modified header, indicating when the collection
+ * was modified last. We pass along this header in our subsequent requests in a X-If-Unmodified-Since
+ * header. Server will ensure that our collection did not change while we are batching, if it did it will
+ * fail our fetch with a 412 (Consequent Modification) error. Additionally, we perform the same checks
+ * locally.
+ */
+public class BatchingDownloader {
+ public static final String LOG_TAG = "BatchingDownloader";
+
+ protected final Server11Repository repository;
+ private final Server11RepositorySession repositorySession;
+ private final DelayedWorkTracker workTracker = new DelayedWorkTracker();
+ // Used to track outstanding requests, so that we can abort them as needed.
+ @VisibleForTesting
+ protected final Set<SyncStorageCollectionRequest> pending = Collections.synchronizedSet(new HashSet<SyncStorageCollectionRequest>());
+ /* @GuardedBy("this") */ private String lastModified;
+ /* @GuardedBy("this") */ private long numRecords = 0;
+
+ public BatchingDownloader(final Server11Repository repository, final Server11RepositorySession repositorySession) {
+ this.repository = repository;
+ this.repositorySession = repositorySession;
+ }
+
+ @VisibleForTesting
+ protected static String flattenIDs(String[] guids) {
+ // Consider using Utils.toDelimitedString if and when the signature changes
+ // to Collection<String> guids.
+ if (guids.length == 0) {
+ return "";
+ }
+ if (guids.length == 1) {
+ return guids[0];
+ }
+ // Assuming 12-char GUIDs. There should be a -1 in there, but we accumulate one comma too many.
+ StringBuilder b = new StringBuilder(guids.length * 12 + guids.length);
+ for (String guid : guids) {
+ b.append(guid);
+ b.append(",");
+ }
+ return b.substring(0, b.length() - 1);
+ }
+
+ @VisibleForTesting
+ protected void fetchWithParameters(long newer,
+ long batchLimit,
+ boolean full,
+ String sort,
+ String ids,
+ SyncStorageCollectionRequest request,
+ RepositorySessionFetchRecordsDelegate fetchRecordsDelegate)
+ throws URISyntaxException, UnsupportedEncodingException {
+ if (batchLimit > repository.getDefaultTotalLimit()) {
+ throw new IllegalArgumentException("Batch limit should not be greater than total limit");
+ }
+
+ request.delegate = new BatchingDownloaderDelegate(this, fetchRecordsDelegate, request,
+ newer, batchLimit, full, sort, ids);
+ this.pending.add(request);
+ request.get();
+ }
+
+ @VisibleForTesting
+ @Nullable
+ protected String encodeParam(String param) throws UnsupportedEncodingException {
+ if (param != null) {
+ return URLEncoder.encode(param, "UTF-8");
+ }
+ return null;
+ }
+
+ @VisibleForTesting
+ protected SyncStorageCollectionRequest makeSyncStorageCollectionRequest(long newer,
+ long batchLimit,
+ boolean full,
+ String sort,
+ String ids,
+ String offset)
+ throws URISyntaxException, UnsupportedEncodingException {
+ URI collectionURI = repository.collectionURI(full, newer, batchLimit, sort, ids, encodeParam(offset));
+ Logger.debug(LOG_TAG, collectionURI.toString());
+
+ return new SyncStorageCollectionRequest(collectionURI);
+ }
+
+ public void fetchSince(long timestamp, RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) {
+ this.fetchSince(timestamp, null, fetchRecordsDelegate);
+ }
+
+ private void fetchSince(long timestamp, String offset,
+ RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) {
+ long batchLimit = repository.getDefaultBatchLimit();
+ String sort = repository.getDefaultSort();
+
+ try {
+ SyncStorageCollectionRequest request = makeSyncStorageCollectionRequest(timestamp,
+ batchLimit, true, sort, null, offset);
+ this.fetchWithParameters(timestamp, batchLimit, true, sort, null, request, fetchRecordsDelegate);
+ } catch (URISyntaxException | UnsupportedEncodingException e) {
+ fetchRecordsDelegate.onFetchFailed(e, null);
+ }
+ }
+
+ public void fetch(String[] guids, RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) {
+ String ids = flattenIDs(guids);
+ String index = "index";
+
+ try {
+ SyncStorageCollectionRequest request = makeSyncStorageCollectionRequest(
+ -1, -1, true, index, ids, null);
+ this.fetchWithParameters(-1, -1, true, index, ids, request, fetchRecordsDelegate);
+ } catch (URISyntaxException | UnsupportedEncodingException e) {
+ fetchRecordsDelegate.onFetchFailed(e, null);
+ }
+ }
+
+ public Server11Repository getServerRepository() {
+ return this.repository;
+ }
+
+ public void onFetchCompleted(SyncStorageResponse response,
+ final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate,
+ final SyncStorageCollectionRequest request, long newer,
+ long limit, boolean full, String sort, String ids) {
+ removeRequestFromPending(request);
+
+ // When we process our first request, we get back a X-Last-Modified header indicating when collection was modified last.
+ // We pass it to the server with every subsequent request (if we need to make more) as the X-If-Unmodified-Since header,
+ // and server is supposed to ensure that this pre-condition is met, and fail our request with a 412 error code otherwise.
+ // So, if all of this happens, these checks should never fail.
+ // However, we also track this header in client side, and can defensively validate against it here as well.
+ final String currentLastModifiedTimestamp = response.lastModified();
+ Logger.debug(LOG_TAG, "Last modified timestamp " + currentLastModifiedTimestamp);
+
+ // Sanity check. We also did a null check in delegate before passing it into here.
+ if (currentLastModifiedTimestamp == null) {
+ this.abort(fetchRecordsDelegate, "Last modified timestamp is missing");
+ return;
+ }
+
+ final boolean lastModifiedChanged;
+ synchronized (this) {
+ if (this.lastModified == null) {
+ // First time seeing last modified timestamp.
+ this.lastModified = currentLastModifiedTimestamp;
+ }
+ lastModifiedChanged = !this.lastModified.equals(currentLastModifiedTimestamp);
+ }
+
+ if (lastModifiedChanged) {
+ this.abort(fetchRecordsDelegate, "Last modified timestamp has changed unexpectedly");
+ return;
+ }
+
+ final boolean hasNotReachedLimit;
+ synchronized (this) {
+ this.numRecords += response.weaveRecords();
+ hasNotReachedLimit = this.numRecords < repository.getDefaultTotalLimit();
+ }
+
+ final String offset = response.weaveOffset();
+ final SyncStorageCollectionRequest newRequest;
+ try {
+ newRequest = makeSyncStorageCollectionRequest(newer,
+ limit, full, sort, ids, offset);
+ } catch (final URISyntaxException | UnsupportedEncodingException e) {
+ this.workTracker.delayWorkItem(new Runnable() {
+ @Override
+ public void run() {
+ Logger.debug(LOG_TAG, "Delayed onFetchCompleted running.");
+ fetchRecordsDelegate.onFetchFailed(e, null);
+ }
+ });
+ return;
+ }
+
+ if (offset != null && hasNotReachedLimit) {
+ try {
+ this.fetchWithParameters(newer, limit, full, sort, ids, newRequest, fetchRecordsDelegate);
+ } catch (final URISyntaxException | UnsupportedEncodingException e) {
+ this.workTracker.delayWorkItem(new Runnable() {
+ @Override
+ public void run() {
+ Logger.debug(LOG_TAG, "Delayed onFetchCompleted running.");
+ fetchRecordsDelegate.onFetchFailed(e, null);
+ }
+ });
+ }
+ return;
+ }
+
+ final long normalizedTimestamp = response.normalizedTimestampForHeader(SyncResponse.X_LAST_MODIFIED);
+ Logger.debug(LOG_TAG, "Fetch completed. Timestamp is " + normalizedTimestamp);
+
+ this.workTracker.delayWorkItem(new Runnable() {
+ @Override
+ public void run() {
+ Logger.debug(LOG_TAG, "Delayed onFetchCompleted running.");
+ fetchRecordsDelegate.onFetchCompleted(normalizedTimestamp);
+ }
+ });
+ }
+
+ public void onFetchFailed(final Exception ex,
+ final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate,
+ final SyncStorageCollectionRequest request) {
+ removeRequestFromPending(request);
+ this.workTracker.delayWorkItem(new Runnable() {
+ @Override
+ public void run() {
+ Logger.debug(LOG_TAG, "Running onFetchFailed.");
+ fetchRecordsDelegate.onFetchFailed(ex, null);
+ }
+ });
+ }
+
+ public void onFetchedRecord(CryptoRecord record,
+ RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) {
+ this.workTracker.incrementOutstanding();
+ try {
+ fetchRecordsDelegate.onFetchedRecord(record);
+ } catch (Exception ex) {
+ Logger.warn(LOG_TAG, "Got exception calling onFetchedRecord with WBO.", ex);
+ throw new RuntimeException(ex);
+ } finally {
+ this.workTracker.decrementOutstanding();
+ }
+ }
+
+ private void removeRequestFromPending(SyncStorageCollectionRequest request) {
+ if (request == null) {
+ return;
+ }
+ this.pending.remove(request);
+ }
+
+ @VisibleForTesting
+ protected void abortRequests() {
+ this.repositorySession.abort();
+ synchronized (this.pending) {
+ for (SyncStorageCollectionRequest request : this.pending) {
+ request.abort();
+ }
+ this.pending.clear();
+ }
+ }
+
+ @Nullable
+ protected synchronized String getLastModified() {
+ return this.lastModified;
+ }
+
+ private void abort(final RepositorySessionFetchRecordsDelegate delegate, final String msg) {
+ Logger.error(LOG_TAG, msg);
+ this.abortRequests();
+ this.workTracker.delayWorkItem(new Runnable() {
+ @Override
+ public void run() {
+ Logger.debug(LOG_TAG, "Delayed onFetchCompleted running.");
+ delegate.onFetchFailed(
+ new IllegalStateException(msg),
+ null);
+ }
+ });
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegate.java
new file mode 100644
index 000000000..eb9f76d6b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegate.java
@@ -0,0 +1,91 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.downloaders;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageCollectionRequest;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.net.WBOCollectionRequestDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+
+/**
+ * Delegate that gets passed into fetch methods to handle server response from fetch.
+ */
+public class BatchingDownloaderDelegate extends WBOCollectionRequestDelegate {
+ public static final String LOG_TAG = "BatchingDownloaderDelegate";
+
+ private BatchingDownloader downloader;
+ private RepositorySessionFetchRecordsDelegate fetchRecordsDelegate;
+ public SyncStorageCollectionRequest request;
+ // Used to pass back to BatchDownloader to start another fetch with these parameters if needed.
+ private long newer;
+ private long batchLimit;
+ private boolean full;
+ private String sort;
+ private String ids;
+
+ public BatchingDownloaderDelegate(final BatchingDownloader downloader,
+ final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate,
+ final SyncStorageCollectionRequest request, long newer,
+ long batchLimit, boolean full, String sort, String ids) {
+ this.downloader = downloader;
+ this.fetchRecordsDelegate = fetchRecordsDelegate;
+ this.request = request;
+ this.newer = newer;
+ this.batchLimit = batchLimit;
+ this.full = full;
+ this.sort = sort;
+ this.ids = ids;
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return this.downloader.getServerRepository().getAuthHeaderProvider();
+ }
+
+ @Override
+ public String ifUnmodifiedSince() {
+ return this.downloader.getLastModified();
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ Logger.debug(LOG_TAG, "Fetch done.");
+ if (response.lastModified() != null) {
+ this.downloader.onFetchCompleted(response, this.fetchRecordsDelegate, this.request,
+ this.newer, this.batchLimit, this.full, this.sort, this.ids);
+ return;
+ }
+ this.downloader.onFetchFailed(
+ new IllegalStateException("Missing last modified header from response"),
+ this.fetchRecordsDelegate,
+ this.request);
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ this.handleRequestError(new HTTPFailureException(response));
+ }
+
+ @Override
+ public void handleRequestError(final Exception ex) {
+ Logger.warn(LOG_TAG, "Got request error.", ex);
+ this.downloader.onFetchFailed(ex, this.fetchRecordsDelegate, this.request);
+ }
+
+ @Override
+ public void handleWBO(CryptoRecord record) {
+ this.downloader.onFetchedRecord(record, this.fetchRecordsDelegate);
+ }
+
+ @Override
+ public KeyBundle keyBundle() {
+ return null;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchMeta.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchMeta.java
new file mode 100644
index 000000000..951588586
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchMeta.java
@@ -0,0 +1,165 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+import android.support.annotation.CheckResult;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.mozilla.gecko.sync.repositories.uploaders.BatchingUploader.TokenModifiedException;
+import org.mozilla.gecko.sync.repositories.uploaders.BatchingUploader.LastModifiedChangedUnexpectedly;
+import org.mozilla.gecko.sync.repositories.uploaders.BatchingUploader.LastModifiedDidNotChange;
+
+/**
+ * Keeps track of token, Last-Modified value and GUIDs of succeeded records.
+ */
+/* @ThreadSafe */
+public class BatchMeta extends BufferSizeTracker {
+ private static final String LOG_TAG = "BatchMeta";
+
+ // Will be set once first payload upload succeeds. We don't expect this to change until we
+ // commit the batch, and which point it must change.
+ /* @GuardedBy("this") */ private Long lastModified;
+
+ // Will be set once first payload upload succeeds. We don't expect this to ever change until
+ // a commit succeeds, at which point this gets set to null.
+ /* @GuardedBy("this") */ private String token;
+
+ /* @GuardedBy("accessLock") */ private boolean isUnlimited = false;
+
+ // Accessed by synchronously running threads.
+ /* @GuardedBy("accessLock") */ private final List<String> successRecordGuids = new ArrayList<>();
+
+ /* @GuardedBy("accessLock") */ private boolean needsCommit = false;
+
+ protected final Long collectionLastModified;
+
+ public BatchMeta(@NonNull Object payloadLock, long maxBytes, long maxRecords, @Nullable Long collectionLastModified) {
+ super(payloadLock, maxBytes, maxRecords);
+ this.collectionLastModified = collectionLastModified;
+ }
+
+ protected void setIsUnlimited(boolean isUnlimited) {
+ synchronized (accessLock) {
+ this.isUnlimited = isUnlimited;
+ }
+ }
+
+ @Override
+ protected boolean canFit(long recordDeltaByteCount) {
+ synchronized (accessLock) {
+ return isUnlimited || super.canFit(recordDeltaByteCount);
+ }
+ }
+
+ @Override
+ @CheckResult
+ protected boolean addAndEstimateIfFull(long recordDeltaByteCount) {
+ synchronized (accessLock) {
+ needsCommit = true;
+ boolean isFull = super.addAndEstimateIfFull(recordDeltaByteCount);
+ return !isUnlimited && isFull;
+ }
+ }
+
+ protected boolean needToCommit() {
+ synchronized (accessLock) {
+ return needsCommit;
+ }
+ }
+
+ protected synchronized String getToken() {
+ return token;
+ }
+
+ protected synchronized void setToken(final String newToken, boolean isCommit) throws TokenModifiedException {
+ // Set token once in a batching mode.
+ // In a non-batching mode, this.token and newToken will be null, and this is a no-op.
+ if (token == null) {
+ token = newToken;
+ return;
+ }
+
+ // Sanity checks.
+ if (isCommit) {
+ // We expect token to be null when commit payload succeeds.
+ if (newToken != null) {
+ throw new TokenModifiedException();
+ } else {
+ token = null;
+ }
+ return;
+ }
+
+ // We expect new token to always equal current token for non-commit payloads.
+ if (!token.equals(newToken)) {
+ throw new TokenModifiedException();
+ }
+ }
+
+ protected synchronized Long getLastModified() {
+ if (lastModified == null) {
+ return collectionLastModified;
+ }
+ return lastModified;
+ }
+
+ protected synchronized void setLastModified(final Long newLastModified, final boolean expectedToChange) throws LastModifiedChangedUnexpectedly, LastModifiedDidNotChange {
+ if (lastModified == null) {
+ lastModified = newLastModified;
+ return;
+ }
+
+ if (!expectedToChange && !lastModified.equals(newLastModified)) {
+ Logger.debug(LOG_TAG, "Last-Modified timestamp changed when we didn't expect it");
+ throw new LastModifiedChangedUnexpectedly();
+
+ } else if (expectedToChange && lastModified.equals(newLastModified)) {
+ Logger.debug(LOG_TAG, "Last-Modified timestamp did not change when we expected it to");
+ throw new LastModifiedDidNotChange();
+
+ } else {
+ lastModified = newLastModified;
+ }
+ }
+
+ protected ArrayList<String> getSuccessRecordGuids() {
+ synchronized (accessLock) {
+ return new ArrayList<>(this.successRecordGuids);
+ }
+ }
+
+ protected void recordSucceeded(final String recordGuid) {
+ // Sanity check.
+ if (recordGuid == null) {
+ throw new IllegalStateException();
+ }
+
+ synchronized (accessLock) {
+ successRecordGuids.add(recordGuid);
+ }
+ }
+
+ @Override
+ protected boolean canFitRecordByteDelta(long byteDelta, long recordCount, long byteCount) {
+ return isUnlimited || super.canFitRecordByteDelta(byteDelta, recordCount, byteCount);
+ }
+
+ @Override
+ protected void reset() {
+ synchronized (accessLock) {
+ super.reset();
+ token = null;
+ lastModified = null;
+ successRecordGuids.clear();
+ needsCommit = false;
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploader.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploader.java
new file mode 100644
index 000000000..26efbd136
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploader.java
@@ -0,0 +1,344 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+import android.net.Uri;
+import android.support.annotation.VisibleForTesting;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.InfoConfiguration;
+import org.mozilla.gecko.sync.Server11RecordPostFailedException;
+import org.mozilla.gecko.sync.net.SyncResponse;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.Server11RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import java.util.ArrayList;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Uploader which implements batching introduced in Sync 1.5.
+ *
+ * Batch vs payload terminology:
+ * - batch is comprised of a series of payloads, which are all committed at the same time.
+ * -- identified via a "batch token", which is returned after first payload for the batch has been uploaded.
+ * - payload is a collection of records which are uploaded together. Associated with a batch.
+ * -- last payload, identified via commit=true, commits the batch.
+ *
+ * Limits for how many records can fit into a payload and into a batch are defined in the passed-in
+ * InfoConfiguration object.
+ *
+ * If we can't fit everything we'd like to upload into one batch (according to max-total-* limits),
+ * then we commit that batch, and start a new one. There are no explicit limits on total number of
+ * batches we might use, although at some point we'll start to run into storage limit errors from the API.
+ *
+ * Once we go past using one batch this uploader is no longer "atomic". Partial state is exposed
+ * to other clients after our first batch is committed and before our last batch is committed.
+ * However, our per-batch limits are high, X-I-U-S mechanics help protect downloading clients
+ * (as long as they implement X-I-U-S) with 412 error codes in case of interleaving upload and download,
+ * and most mobile clients will not be uploading large-enough amounts of data (especially structured
+ * data, such as bookmarks).
+ *
+ * Last-Modified header returned with the first batch payload POST success is maintained for a batch,
+ * to guard against concurrent-modification errors (different uploader commits before we're done).
+ *
+ * Non-batching mode notes:
+ * We also support Sync servers which don't enable batching for uploads. In this case, we respect
+ * payload limits for individual uploads, and every upload is considered a commit. Batching limits
+ * do not apply, and batch token is irrelevant.
+ * We do keep track of Last-Modified and send along X-I-U-S with our uploads, to protect against
+ * concurrent modifications by other clients.
+ */
+public class BatchingUploader {
+ private static final String LOG_TAG = "BatchingUploader";
+
+ private final Uri collectionUri;
+
+ private volatile boolean recordUploadFailed = false;
+
+ private final BatchMeta batchMeta;
+ private final Payload payload;
+
+ // Accessed by synchronously running threads, OK to not synchronize and just make it volatile.
+ private volatile Boolean inBatchingMode;
+
+ // Used to ensure we have thread-safe access to the following:
+ // - byte and record counts in both Payload and BatchMeta objects
+ // - buffers in the Payload object
+ private final Object payloadLock = new Object();
+
+ protected Executor workQueue;
+ protected final RepositorySessionStoreDelegate sessionStoreDelegate;
+ protected final Server11RepositorySession repositorySession;
+
+ protected AtomicLong uploadTimestamp = new AtomicLong(0);
+
+ protected static final int PER_RECORD_OVERHEAD_BYTE_COUNT = RecordUploadRunnable.RECORD_SEPARATOR.length;
+ protected static final int PER_PAYLOAD_OVERHEAD_BYTE_COUNT = RecordUploadRunnable.RECORDS_END.length;
+
+ // Sanity check. RECORD_SEPARATOR and RECORD_START are assumed to be of the same length.
+ static {
+ if (RecordUploadRunnable.RECORD_SEPARATOR.length != RecordUploadRunnable.RECORDS_START.length) {
+ throw new IllegalStateException("Separator and start tokens must be of the same length");
+ }
+ }
+
+ public BatchingUploader(final Server11RepositorySession repositorySession, final Executor workQueue, final RepositorySessionStoreDelegate sessionStoreDelegate) {
+ this.repositorySession = repositorySession;
+ this.workQueue = workQueue;
+ this.sessionStoreDelegate = sessionStoreDelegate;
+ this.collectionUri = Uri.parse(repositorySession.getServerRepository().collectionURI().toString());
+
+ InfoConfiguration config = repositorySession.getServerRepository().getInfoConfiguration();
+ this.batchMeta = new BatchMeta(
+ payloadLock, config.maxTotalBytes, config.maxTotalRecords,
+ repositorySession.getServerRepository().getCollectionLastModified()
+ );
+ this.payload = new Payload(payloadLock, config.maxPostBytes, config.maxPostRecords);
+ }
+
+ public void process(final Record record) {
+ final String guid = record.guid;
+ final byte[] recordBytes = record.toJSONBytes();
+ final long recordDeltaByteCount = recordBytes.length + PER_RECORD_OVERHEAD_BYTE_COUNT;
+
+ Logger.debug(LOG_TAG, "Processing a record with guid: " + guid);
+
+ // We can't upload individual records which exceed our payload byte limit.
+ if ((recordDeltaByteCount + PER_PAYLOAD_OVERHEAD_BYTE_COUNT) > payload.maxBytes) {
+ sessionStoreDelegate.onRecordStoreFailed(new RecordTooLargeToUpload(), guid);
+ return;
+ }
+
+ synchronized (payloadLock) {
+ final boolean canFitRecordIntoBatch = batchMeta.canFit(recordDeltaByteCount);
+ final boolean canFitRecordIntoPayload = payload.canFit(recordDeltaByteCount);
+
+ // Record fits!
+ if (canFitRecordIntoBatch && canFitRecordIntoPayload) {
+ Logger.debug(LOG_TAG, "Record fits into the current batch and payload");
+ addAndFlushIfNecessary(recordDeltaByteCount, recordBytes, guid);
+
+ // Payload won't fit the record.
+ } else if (canFitRecordIntoBatch) {
+ Logger.debug(LOG_TAG, "Current payload won't fit incoming record, uploading payload.");
+ flush(false, false);
+
+ Logger.debug(LOG_TAG, "Recording the incoming record into a new payload");
+
+ // Keep track of the overflow record.
+ addAndFlushIfNecessary(recordDeltaByteCount, recordBytes, guid);
+
+ // Batch won't fit the record.
+ } else {
+ Logger.debug(LOG_TAG, "Current batch won't fit incoming record, committing batch.");
+ flush(true, false);
+
+ Logger.debug(LOG_TAG, "Recording the incoming record into a new batch");
+ batchMeta.reset();
+
+ // Keep track of the overflow record.
+ addAndFlushIfNecessary(recordDeltaByteCount, recordBytes, guid);
+ }
+ }
+ }
+
+ // Convenience function used from the process method; caller must hold a payloadLock.
+ private void addAndFlushIfNecessary(long byteCount, byte[] recordBytes, String guid) {
+ boolean isPayloadFull = payload.addAndEstimateIfFull(byteCount, recordBytes, guid);
+ boolean isBatchFull = batchMeta.addAndEstimateIfFull(byteCount);
+
+ // Preemptive commit batch or upload a payload if they're estimated to be full.
+ if (isBatchFull) {
+ flush(true, false);
+ batchMeta.reset();
+ } else if (isPayloadFull) {
+ flush(false, false);
+ }
+ }
+
+ public void noMoreRecordsToUpload() {
+ Logger.debug(LOG_TAG, "Received 'no more records to upload' signal.");
+
+ // Run this after the last payload succeeds, so that we know for sure if we're in a batching
+ // mode and need to commit with a potentially empty payload.
+ workQueue.execute(new Runnable() {
+ @Override
+ public void run() {
+ commitIfNecessaryAfterLastPayload();
+ }
+ });
+ }
+
+ @VisibleForTesting
+ protected void commitIfNecessaryAfterLastPayload() {
+ // Must be called after last payload upload finishes.
+ synchronized (payload) {
+ // If we have any pending records in the Payload, flush them!
+ if (!payload.isEmpty()) {
+ flush(true, true);
+
+ // If we have an empty payload but need to commit the batch in the batching mode, flush!
+ } else if (batchMeta.needToCommit() && Boolean.TRUE.equals(inBatchingMode)) {
+ flush(true, true);
+
+ // Otherwise, we're done.
+ } else {
+ finished(uploadTimestamp);
+ }
+ }
+ }
+
+ /**
+ * We've been told by our upload delegate that a payload succeeded.
+ * Depending on the type of payload and batch mode status, inform our delegate of progress.
+ *
+ * @param response success response to our commit post
+ * @param isCommit was this a commit upload?
+ * @param isLastPayload was this a very last payload we'll upload?
+ */
+ public void payloadSucceeded(final SyncStorageResponse response, final boolean isCommit, final boolean isLastPayload) {
+ // Sanity check.
+ if (inBatchingMode == null) {
+ throw new IllegalStateException("Can't process payload success until we know if we're in a batching mode");
+ }
+
+ // We consider records to have been committed if we're not in a batching mode or this was a commit.
+ // If records have been committed, notify our store delegate.
+ if (!inBatchingMode || isCommit) {
+ for (String guid : batchMeta.getSuccessRecordGuids()) {
+ sessionStoreDelegate.onRecordStoreSucceeded(guid);
+ }
+ }
+
+ // If this was our very last commit, we're done storing records.
+ // Get Last-Modified timestamp from the response, and pass it upstream.
+ if (isLastPayload) {
+ finished(response.normalizedTimestampForHeader(SyncResponse.X_LAST_MODIFIED));
+ }
+ }
+
+ public void lastPayloadFailed() {
+ finished(uploadTimestamp);
+ }
+
+ private void finished(long lastModifiedTimestamp) {
+ bumpTimestampTo(uploadTimestamp, lastModifiedTimestamp);
+ finished(uploadTimestamp);
+ }
+
+ private void finished(AtomicLong lastModifiedTimestamp) {
+ repositorySession.storeDone(lastModifiedTimestamp.get());
+ }
+
+ public BatchMeta getCurrentBatch() {
+ return batchMeta;
+ }
+
+ public void setInBatchingMode(boolean inBatchingMode) {
+ this.inBatchingMode = inBatchingMode;
+
+ // If we know for sure that we're not in a batching mode,
+ // consider our batch to be of unlimited size.
+ this.batchMeta.setIsUnlimited(!inBatchingMode);
+ }
+
+ public Boolean getInBatchingMode() {
+ return inBatchingMode;
+ }
+
+ public void setLastModified(final Long lastModified, final boolean isCommit) throws BatchingUploaderException {
+ // Sanity check.
+ if (inBatchingMode == null) {
+ throw new IllegalStateException("Can't process Last-Modified before we know we're in a batching mode.");
+ }
+
+ // In non-batching mode, every time we receive a Last-Modified timestamp, we expect it to change
+ // since records are "committed" (become visible to other clients) on every payload.
+ // In batching mode, we only expect Last-Modified to change when we commit a batch.
+ batchMeta.setLastModified(lastModified, isCommit || !inBatchingMode);
+ }
+
+ public void recordSucceeded(final String recordGuid) {
+ Logger.debug(LOG_TAG, "Record store succeeded: " + recordGuid);
+ batchMeta.recordSucceeded(recordGuid);
+ }
+
+ public void recordFailed(final String recordGuid) {
+ recordFailed(new Server11RecordPostFailedException(), recordGuid);
+ }
+
+ public void recordFailed(final Exception e, final String recordGuid) {
+ Logger.debug(LOG_TAG, "Record store failed for guid " + recordGuid + " with exception: " + e.toString());
+ recordUploadFailed = true;
+ sessionStoreDelegate.onRecordStoreFailed(e, recordGuid);
+ }
+
+ public Server11RepositorySession getRepositorySession() {
+ return repositorySession;
+ }
+
+ private static void bumpTimestampTo(final AtomicLong current, long newValue) {
+ while (true) {
+ long existing = current.get();
+ if (existing > newValue) {
+ return;
+ }
+ if (current.compareAndSet(existing, newValue)) {
+ return;
+ }
+ }
+ }
+
+ private void flush(final boolean isCommit, final boolean isLastPayload) {
+ final ArrayList<byte[]> outgoing;
+ final ArrayList<String> outgoingGuids;
+ final long byteCount;
+
+ // Even though payload object itself is thread-safe, we want to ensure we get these altogether
+ // as a "unit". Another approach would be to create a wrapper object for these values, but this works.
+ synchronized (payloadLock) {
+ outgoing = payload.getRecordsBuffer();
+ outgoingGuids = payload.getRecordGuidsBuffer();
+ byteCount = payload.getByteCount();
+ }
+
+ workQueue.execute(new RecordUploadRunnable(
+ new BatchingAtomicUploaderMayUploadProvider(),
+ collectionUri,
+ batchMeta,
+ new PayloadUploadDelegate(this, outgoingGuids, isCommit, isLastPayload),
+ outgoing,
+ byteCount,
+ isCommit
+ ));
+
+ payload.reset();
+ }
+
+ private class BatchingAtomicUploaderMayUploadProvider implements MayUploadProvider {
+ public boolean mayUpload() {
+ return !recordUploadFailed;
+ }
+ }
+
+ public static class BatchingUploaderException extends Exception {
+ private static final long serialVersionUID = 1L;
+ }
+ public static class RecordTooLargeToUpload extends BatchingUploaderException {
+ private static final long serialVersionUID = 1L;
+ }
+ public static class LastModifiedDidNotChange extends BatchingUploaderException {
+ private static final long serialVersionUID = 1L;
+ }
+ public static class LastModifiedChangedUnexpectedly extends BatchingUploaderException {
+ private static final long serialVersionUID = 1L;
+ }
+ public static class TokenModifiedException extends BatchingUploaderException {
+ private static final long serialVersionUID = 1L;
+ };
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BufferSizeTracker.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BufferSizeTracker.java
new file mode 100644
index 000000000..7f4c305f3
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BufferSizeTracker.java
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+import android.support.annotation.CallSuper;
+import android.support.annotation.CheckResult;
+
+/**
+ * Implements functionality shared by BatchMeta and Payload objects, namely:
+ * - keeping track of byte and record counts
+ * - incrementing those counts when records are added
+ * - checking if a record can fit
+ */
+/* @ThreadSafe */
+public abstract class BufferSizeTracker {
+ protected final Object accessLock;
+
+ /* @GuardedBy("accessLock") */ private long byteCount = BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT;
+ /* @GuardedBy("accessLock") */ private long recordCount = 0;
+ /* @GuardedBy("accessLock") */ protected Long smallestRecordByteCount;
+
+ protected final long maxBytes;
+ protected final long maxRecords;
+
+ public BufferSizeTracker(Object accessLock, long maxBytes, long maxRecords) {
+ this.accessLock = accessLock;
+ this.maxBytes = maxBytes;
+ this.maxRecords = maxRecords;
+ }
+
+ @CallSuper
+ protected boolean canFit(long recordDeltaByteCount) {
+ synchronized (accessLock) {
+ return canFitRecordByteDelta(recordDeltaByteCount, recordCount, byteCount);
+ }
+ }
+
+ protected boolean isEmpty() {
+ synchronized (accessLock) {
+ return recordCount == 0;
+ }
+ }
+
+ /**
+ * Adds a record and returns a boolean indicating whether batch is estimated to be full afterwards.
+ */
+ @CheckResult
+ protected boolean addAndEstimateIfFull(long recordDeltaByteCount) {
+ synchronized (accessLock) {
+ // Sanity check. Calling this method when buffer won't fit the record is an error.
+ if (!canFitRecordByteDelta(recordDeltaByteCount, recordCount, byteCount)) {
+ throw new IllegalStateException("Buffer size exceeded");
+ }
+
+ byteCount += recordDeltaByteCount;
+ recordCount += 1;
+
+ if (smallestRecordByteCount == null || smallestRecordByteCount > recordDeltaByteCount) {
+ smallestRecordByteCount = recordDeltaByteCount;
+ }
+
+ // See if we're full or nearly full after adding a record.
+ // We're halving smallestRecordByteCount because we're erring
+ // on the side of "can hopefully fit". We're trying to upload as soon as we know we
+ // should, but we also need to be mindful of minimizing total number of uploads we make.
+ return !canFitRecordByteDelta(smallestRecordByteCount / 2, recordCount, byteCount);
+ }
+ }
+
+ protected long getByteCount() {
+ synchronized (accessLock) {
+ // Ensure we account for payload overhead twice when the batch is empty.
+ // Payload overhead is either RECORDS_START ("[") or RECORDS_END ("]"),
+ // and for an empty payload we need account for both ("[]").
+ if (recordCount == 0) {
+ return byteCount + BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT;
+ }
+ return byteCount;
+ }
+ }
+
+ protected long getRecordCount() {
+ synchronized (accessLock) {
+ return recordCount;
+ }
+ }
+
+ @CallSuper
+ protected void reset() {
+ synchronized (accessLock) {
+ byteCount = BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT;
+ recordCount = 0;
+ }
+ }
+
+ @CallSuper
+ protected boolean canFitRecordByteDelta(long byteDelta, long recordCount, long byteCount) {
+ return recordCount < maxRecords
+ && (byteCount + byteDelta) <= maxBytes;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/MayUploadProvider.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/MayUploadProvider.java
new file mode 100644
index 000000000..a1994cf62
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/MayUploadProvider.java
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+public interface MayUploadProvider {
+ boolean mayUpload();
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/Payload.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/Payload.java
new file mode 100644
index 000000000..1ed9b5798
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/Payload.java
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+import android.support.annotation.CheckResult;
+
+import java.util.ArrayList;
+
+/**
+ * Owns per-payload record byte and recordGuid buffers.
+ */
+/* @ThreadSafe */
+public class Payload extends BufferSizeTracker {
+ // Data of outbound records.
+ /* @GuardedBy("accessLock") */ private final ArrayList<byte[]> recordsBuffer = new ArrayList<>();
+
+ // GUIDs of outbound records. Used to fail entire payloads.
+ /* @GuardedBy("accessLock") */ private final ArrayList<String> recordGuidsBuffer = new ArrayList<>();
+
+ public Payload(Object payloadLock, long maxBytes, long maxRecords) {
+ super(payloadLock, maxBytes, maxRecords);
+ }
+
+ @Override
+ protected boolean addAndEstimateIfFull(long recordDelta) {
+ throw new UnsupportedOperationException();
+ }
+
+ @CheckResult
+ protected boolean addAndEstimateIfFull(long recordDelta, byte[] recordBytes, String guid) {
+ synchronized (accessLock) {
+ recordsBuffer.add(recordBytes);
+ recordGuidsBuffer.add(guid);
+ return super.addAndEstimateIfFull(recordDelta);
+ }
+ }
+
+ @Override
+ protected void reset() {
+ synchronized (accessLock) {
+ super.reset();
+ recordsBuffer.clear();
+ recordGuidsBuffer.clear();
+ }
+ }
+
+ protected ArrayList<byte[]> getRecordsBuffer() {
+ synchronized (accessLock) {
+ return new ArrayList<>(recordsBuffer);
+ }
+ }
+
+ protected ArrayList<String> getRecordGuidsBuffer() {
+ synchronized (accessLock) {
+ return new ArrayList<>(recordGuidsBuffer);
+ }
+ }
+
+ protected boolean isEmpty() {
+ synchronized (accessLock) {
+ return recordsBuffer.isEmpty();
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/PayloadUploadDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/PayloadUploadDelegate.java
new file mode 100644
index 000000000..e8bbb7df6
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/PayloadUploadDelegate.java
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.NonArrayJSONException;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncResponse;
+import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+import java.util.ArrayList;
+
+public class PayloadUploadDelegate implements SyncStorageRequestDelegate {
+ private static final String LOG_TAG = "PayloadUploadDelegate";
+
+ private static final String KEY_BATCH = "batch";
+
+ private final BatchingUploader uploader;
+ private ArrayList<String> postedRecordGuids;
+ private final boolean isCommit;
+ private final boolean isLastPayload;
+
+ public PayloadUploadDelegate(BatchingUploader uploader, ArrayList<String> postedRecordGuids, boolean isCommit, boolean isLastPayload) {
+ this.uploader = uploader;
+ this.postedRecordGuids = postedRecordGuids;
+ this.isCommit = isCommit;
+ this.isLastPayload = isLastPayload;
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return uploader.getRepositorySession().getServerRepository().getAuthHeaderProvider();
+ }
+
+ @Override
+ public String ifUnmodifiedSince() {
+ final Long lastModified = uploader.getCurrentBatch().getLastModified();
+ if (lastModified == null) {
+ return null;
+ }
+ return Utils.millisecondsToDecimalSecondsString(lastModified);
+ }
+
+ @Override
+ public void handleRequestSuccess(final SyncStorageResponse response) {
+ // First, do some sanity checking.
+ if (response.getStatusCode() != 200 && response.getStatusCode() != 202) {
+ handleRequestError(
+ new IllegalStateException("handleRequestSuccess received a non-200/202 response: " + response.getStatusCode())
+ );
+ return;
+ }
+
+ // We always expect to see a Last-Modified header. It's returned with every success response.
+ if (!response.httpResponse().containsHeader(SyncResponse.X_LAST_MODIFIED)) {
+ handleRequestError(
+ new IllegalStateException("Response did not have a Last-Modified header")
+ );
+ return;
+ }
+
+ // We expect to be able to parse the response as a JSON object.
+ final ExtendedJSONObject body;
+ try {
+ body = response.jsonObjectBody(); // jsonObjectBody() throws or returns non-null.
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Got exception parsing POST success body.", e);
+ this.handleRequestError(e);
+ return;
+ }
+
+ // If we got a 200, it could be either a non-batching result, or a batch commit.
+ // - if we're in a batching mode, we expect this to be a commit.
+ // If we got a 202, we expect there to be a token present in the response
+ if (response.getStatusCode() == 200 && uploader.getCurrentBatch().getToken() != null) {
+ if (uploader.getInBatchingMode() && !isCommit) {
+ handleRequestError(
+ new IllegalStateException("Got 200 OK in batching mode, but this was not a commit payload")
+ );
+ return;
+ }
+ } else if (response.getStatusCode() == 202) {
+ if (!body.containsKey(KEY_BATCH)) {
+ handleRequestError(
+ new IllegalStateException("Batch response did not have a batch ID")
+ );
+ return;
+ }
+ }
+
+ // With sanity checks out of the way, can now safely say if we're in a batching mode or not.
+ // We only do this once per session.
+ if (uploader.getInBatchingMode() == null) {
+ uploader.setInBatchingMode(body.containsKey(KEY_BATCH));
+ }
+
+ // Tell current batch about the token we've received.
+ // Throws if token changed after being set once, or if we got a non-null token after a commit.
+ try {
+ uploader.getCurrentBatch().setToken(body.getString(KEY_BATCH), isCommit);
+ } catch (BatchingUploader.BatchingUploaderException e) {
+ handleRequestError(e);
+ return;
+ }
+
+ // Will throw if Last-Modified changed when it shouldn't have.
+ try {
+ uploader.setLastModified(
+ response.normalizedTimestampForHeader(SyncResponse.X_LAST_MODIFIED),
+ isCommit);
+ } catch (BatchingUploader.BatchingUploaderException e) {
+ handleRequestError(e);
+ return;
+ }
+
+ // All looks good up to this point, let's process success and failed arrays.
+ JSONArray success;
+ try {
+ success = body.getArray("success");
+ } catch (NonArrayJSONException e) {
+ handleRequestError(e);
+ return;
+ }
+
+ if (success != null && !success.isEmpty()) {
+ Logger.trace(LOG_TAG, "Successful records: " + success.toString());
+ for (Object o : success) {
+ try {
+ uploader.recordSucceeded((String) o);
+ } catch (ClassCastException e) {
+ Logger.error(LOG_TAG, "Got exception parsing POST success guid.", e);
+ // Not much to be done.
+ }
+ }
+ }
+ // GC
+ success = null;
+
+ ExtendedJSONObject failed;
+ try {
+ failed = body.getObject("failed");
+ } catch (NonObjectJSONException e) {
+ handleRequestError(e);
+ return;
+ }
+
+ if (failed != null && !failed.object.isEmpty()) {
+ Logger.debug(LOG_TAG, "Failed records: " + failed.object.toString());
+ for (String guid : failed.keySet()) {
+ uploader.recordFailed(guid);
+ }
+ }
+ // GC
+ failed = null;
+
+ // And we're done! Let uploader finish up.
+ uploader.payloadSucceeded(response, isCommit, isLastPayload);
+ }
+
+ @Override
+ public void handleRequestFailure(final SyncStorageResponse response) {
+ this.handleRequestError(new HTTPFailureException(response));
+ }
+
+ @Override
+ public void handleRequestError(Exception e) {
+ for (String guid : postedRecordGuids) {
+ uploader.recordFailed(e, guid);
+ }
+ // GC
+ postedRecordGuids = null;
+
+ if (isLastPayload) {
+ uploader.lastPayloadFailed();
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/RecordUploadRunnable.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/RecordUploadRunnable.java
new file mode 100644
index 000000000..ce2955102
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/RecordUploadRunnable.java
@@ -0,0 +1,176 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+import android.net.Uri;
+import android.support.annotation.VisibleForTesting;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.Server11PreviousPostFailedException;
+import org.mozilla.gecko.sync.net.SyncStorageRequest;
+import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+
+import ch.boye.httpclientandroidlib.entity.ContentProducer;
+import ch.boye.httpclientandroidlib.entity.EntityTemplate;
+
+/**
+ * Responsible for creating and posting a <code>SyncStorageRequest</code> request object.
+ */
+public class RecordUploadRunnable implements Runnable {
+ public final String LOG_TAG = "RecordUploadRunnable";
+
+ public final static byte[] RECORDS_START = { 91 }; // [ in UTF-8
+ public final static byte[] RECORD_SEPARATOR = { 44 }; // , in UTF-8
+ public final static byte[] RECORDS_END = { 93 }; // ] in UTF-8
+
+ private static final String QUERY_PARAM_BATCH = "batch";
+ private static final String QUERY_PARAM_TRUE = "true";
+ private static final String QUERY_PARAM_BATCH_COMMIT = "commit";
+
+ private final MayUploadProvider mayUploadProvider;
+ private final SyncStorageRequestDelegate uploadDelegate;
+
+ private final ArrayList<byte[]> outgoing;
+ private final long byteCount;
+
+ // Used to construct POST URI during run().
+ @VisibleForTesting
+ public final boolean isCommit;
+ private final Uri collectionUri;
+ private final BatchMeta batchMeta;
+
+ public RecordUploadRunnable(MayUploadProvider mayUploadProvider,
+ Uri collectionUri,
+ BatchMeta batchMeta,
+ SyncStorageRequestDelegate uploadDelegate,
+ ArrayList<byte[]> outgoing,
+ long byteCount,
+ boolean isCommit) {
+ this.mayUploadProvider = mayUploadProvider;
+ this.uploadDelegate = uploadDelegate;
+ this.outgoing = outgoing;
+ this.byteCount = byteCount;
+ this.batchMeta = batchMeta;
+ this.collectionUri = collectionUri;
+ this.isCommit = isCommit;
+ }
+
+ public static class ByteArraysContentProducer implements ContentProducer {
+ ArrayList<byte[]> outgoing;
+ public ByteArraysContentProducer(ArrayList<byte[]> arrays) {
+ outgoing = arrays;
+ }
+
+ @Override
+ public void writeTo(OutputStream outstream) throws IOException {
+ int count = outgoing.size();
+ outstream.write(RECORDS_START);
+ if (count > 0) {
+ outstream.write(outgoing.get(0));
+ for (int i = 1; i < count; ++i) {
+ outstream.write(RECORD_SEPARATOR);
+ outstream.write(outgoing.get(i));
+ }
+ }
+ outstream.write(RECORDS_END);
+ }
+
+ public static long outgoingBytesCount(ArrayList<byte[]> outgoing) {
+ final long numberOfRecords = outgoing.size();
+
+ // Account for start and end tokens.
+ long count = RECORDS_START.length + RECORDS_END.length;
+
+ // Account for all the records.
+ for (int i = 0; i < numberOfRecords; i++) {
+ count += outgoing.get(i).length;
+ }
+
+ // Account for a separator between the records.
+ // There's one less separator than there are records.
+ if (numberOfRecords > 1) {
+ count += RECORD_SEPARATOR.length * (numberOfRecords - 1);
+ }
+
+ return count;
+ }
+ }
+
+ public static class ByteArraysEntity extends EntityTemplate {
+ private final long count;
+ public ByteArraysEntity(ArrayList<byte[]> arrays, long totalBytes) {
+ super(new ByteArraysContentProducer(arrays));
+ this.count = totalBytes;
+ this.setContentType("application/json");
+ // charset is set in BaseResource.
+
+ // Sanity check our byte counts.
+ long realByteCount = ByteArraysContentProducer.outgoingBytesCount(arrays);
+ if (realByteCount != totalBytes) {
+ throw new IllegalStateException("Mismatched byte counts. Received " + totalBytes + " while real byte count is " + realByteCount);
+ }
+ }
+
+ @Override
+ public long getContentLength() {
+ return count;
+ }
+
+ @Override
+ public boolean isRepeatable() {
+ return true;
+ }
+ }
+
+ @Override
+ public void run() {
+ if (!mayUploadProvider.mayUpload()) {
+ Logger.info(LOG_TAG, "Told not to proceed by the uploader. Cancelling upload, failing records.");
+ uploadDelegate.handleRequestError(new Server11PreviousPostFailedException());
+ return;
+ }
+
+ Logger.trace(LOG_TAG, "Running upload task. Outgoing records: " + outgoing.size());
+
+ // We don't want the task queue to proceed until this request completes.
+ // Fortunately, BaseResource is currently synchronous.
+ // If that ever changes, you'll need to block here.
+
+ final URI postURI = buildPostURI(isCommit, batchMeta, collectionUri);
+ final SyncStorageRequest request = new SyncStorageRequest(postURI);
+ request.delegate = uploadDelegate;
+
+ ByteArraysEntity body = new ByteArraysEntity(outgoing, byteCount);
+ request.post(body);
+ }
+
+ @VisibleForTesting
+ public static URI buildPostURI(boolean isCommit, BatchMeta batchMeta, Uri collectionUri) {
+ final Uri.Builder uriBuilder = collectionUri.buildUpon();
+ final String batchToken = batchMeta.getToken();
+
+ if (batchToken != null) {
+ uriBuilder.appendQueryParameter(QUERY_PARAM_BATCH, batchToken);
+ } else {
+ uriBuilder.appendQueryParameter(QUERY_PARAM_BATCH, QUERY_PARAM_TRUE);
+ }
+
+ if (isCommit) {
+ uriBuilder.appendQueryParameter(QUERY_PARAM_BATCH_COMMIT, QUERY_PARAM_TRUE);
+ }
+
+ try {
+ return new URI(uriBuilder.build().toString());
+ } catch (URISyntaxException e) {
+ throw new IllegalStateException("Failed to construct a collection URI", e);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/Constants.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/Constants.java
new file mode 100644
index 000000000..66e6768b4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/Constants.java
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.setup;
+
+public class Constants {
+ public static final String DEFAULT_PROFILE = "default";
+
+ /**
+ * Key in sync extras bundle specifying stages to sync this sync session.
+ * <p>
+ * Corresponding value should be a String JSON-encoding an object, the keys of
+ * which are the stage names to sync. For example:
+ * <code>"{ \"stageToSync\": 0 }"</code>.
+ */
+ public static final String EXTRAS_KEY_STAGES_TO_SYNC = "sync";
+
+ /**
+ * Key in sync extras bundle specifying stages to skip this sync session.
+ * <p>
+ * Corresponding value should be a String JSON-encoding an object, the keys of
+ * which are the stage names to skip. For example:
+ * <code>"{ \"stageToSkip\": 0 }"</code>.
+ */
+ public static final String EXTRAS_KEY_STAGES_TO_SKIP = "skip";
+
+ public static final String JSON_KEY_ACCOUNT = "account";
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/InvalidSyncKeyException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/InvalidSyncKeyException.java
new file mode 100644
index 000000000..ac0fd58d0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/InvalidSyncKeyException.java
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.setup;
+
+public class InvalidSyncKeyException extends Exception {
+ private static final long serialVersionUID = -6504925951580479894L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/ActivityUtils.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/ActivityUtils.java
new file mode 100644
index 000000000..6542e1b00
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/ActivityUtils.java
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.setup.activities;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+
+import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.db.BrowserContract;
+
+public class ActivityUtils {
+ /**
+ * Open a URL in Fennec, if one is provided; or just open Fennec.
+ *
+ * @param context Android context.
+ * @param url to visit, or null to just open Fennec.
+ */
+ public static void openURLInFennec(final Context context, final String url) {
+ Intent intent;
+ if (url != null) {
+ intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse(url));
+ } else {
+ intent = new Intent(Intent.ACTION_MAIN);
+ }
+ intent.setClassName(GlobalConstants.BROWSER_INTENT_PACKAGE, GlobalConstants.BROWSER_INTENT_CLASS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.putExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, true);
+ context.startActivity(intent);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/WebURLFinder.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/WebURLFinder.java
new file mode 100644
index 000000000..8411d2a62
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/setup/activities/WebURLFinder.java
@@ -0,0 +1,161 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.setup.activities;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class WebURLFinder {
+ /**
+ * These regular expressions are taken from Android's Patterns.java.
+ * We brought them in to standardize URL matching across Android versions, instead of relying
+ * on Android version-dependent built-ins that can vary across Android versions.
+ * The original code can be found here:
+ * http://androidxref.com/6.0.1_r10/xref/frameworks/base/core/java/android/util/Patterns.java
+ *
+ */
+ public static final String GOOD_IRI_CHAR = "a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF";
+ public static final String GOOD_GTLD_CHAR = "a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF";
+ public static final String IRI = "[" + GOOD_IRI_CHAR + "]([" + GOOD_IRI_CHAR + "\\-]{0,61}[" + GOOD_IRI_CHAR + "]){0,1}";
+ public static final String GTLD = "[" + GOOD_GTLD_CHAR + "]{2,63}";
+ public static final String HOST_NAME = "(" + IRI + "\\.)+" + GTLD;
+ public static final Pattern IP_ADDRESS = Pattern.compile("((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4]"
+ + "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]"
+ + "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}"
+ + "|[1-9][0-9]|[0-9]))");
+ public static final Pattern DOMAIN_NAME = Pattern.compile("(" + HOST_NAME + "|" + IP_ADDRESS + ")");
+ public static final Pattern WEB_URL = Pattern.compile("((?:(http|https|Http|Https|rtsp|Rtsp):\\/\\/(?:(?:[a-zA-Z0-9\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)"
+ + "\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\$\\-\\_"
+ + "\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@)?)?"
+ + "(?:" + DOMAIN_NAME + ")"
+ + "(?:\\:\\d{1,5})?)"
+ + "(\\/(?:(?:[" + GOOD_IRI_CHAR + "\\;\\/\\?\\:\\@\\&\\=\\#\\~"
+ + "\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])|(?:\\%[a-fA-F0-9]{2}))*)?"
+ + "(?:\\b|$)");
+
+ public final List<String> candidates;
+
+ public WebURLFinder(String string) {
+ if (string == null) {
+ throw new IllegalArgumentException("string must not be null");
+ }
+
+ this.candidates = candidateWebURLs(string);
+ }
+
+ public WebURLFinder(List<String> strings) {
+ if (strings == null) {
+ throw new IllegalArgumentException("strings must not be null");
+ }
+
+ this.candidates = candidateWebURLs(strings);
+ }
+
+ /**
+ * Check if string is a Web URL.
+ * <p>
+ * A Web URL is a URI that is not a <code>file:</code> or
+ * <code>javascript:</code> scheme.
+ *
+ * @param string
+ * to check.
+ * @return <code>true</code> if <code>string</code> is a Web URL.
+ */
+ public static boolean isWebURL(String string) {
+ try {
+ new URI(string);
+ } catch (Exception e) {
+ return false;
+ }
+
+ if (android.webkit.URLUtil.isFileUrl(string) ||
+ android.webkit.URLUtil.isJavaScriptUrl(string)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Return best Web URL.
+ * <p>
+ * "Best" means a Web URL with a scheme, and failing that, a Web URL without a
+ * scheme.
+ *
+ * @return a Web URL or <code>null</code>.
+ */
+ public String bestWebURL() {
+ String firstWebURLWithScheme = firstWebURLWithScheme();
+ if (firstWebURLWithScheme != null) {
+ return firstWebURLWithScheme;
+ }
+
+ return firstWebURLWithoutScheme();
+ }
+
+ protected static List<String> candidateWebURLs(Collection<String> strings) {
+ List<String> candidates = new ArrayList<String>();
+
+ for (String string : strings) {
+ if (string == null) {
+ continue;
+ }
+
+ candidates.addAll(candidateWebURLs(string));
+ }
+
+ return candidates;
+ }
+
+ protected static List<String> candidateWebURLs(String string) {
+ Matcher matcher = WEB_URL.matcher(string);
+ List<String> matches = new LinkedList<String>();
+
+ while (matcher.find()) {
+ // Remove URLs with bad schemes.
+ if (!isWebURL(matcher.group())) {
+ continue;
+ }
+
+ // Remove parts of email addresses.
+ if (matcher.start() > 0 && (string.charAt(matcher.start() - 1) == '@')) {
+ continue;
+ }
+
+ matches.add(matcher.group());
+ }
+
+ return matches;
+ }
+
+ protected String firstWebURLWithScheme() {
+ for (String match : candidates) {
+ try {
+ if (new URI(match).getScheme() != null) {
+ return match;
+ }
+ } catch (URISyntaxException e) {
+ // Ignore: on to the next.
+ continue;
+ }
+ }
+
+ return null;
+ }
+
+ protected String firstWebURLWithoutScheme() {
+ if (!candidates.isEmpty()) {
+ return candidates.get(0);
+ }
+
+ return null;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractNonRepositorySyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractNonRepositorySyncStage.java
new file mode 100644
index 000000000..c910216eb
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractNonRepositorySyncStage.java
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+
+/**
+ * This is simply a stage that is not responsible for synchronizing repositories.
+ */
+public abstract class AbstractNonRepositorySyncStage extends AbstractSessionManagingSyncStage {
+ @Override
+ protected void resetLocal() {
+ // Do nothing.
+ }
+
+ @Override
+ protected void wipeLocal() {
+ // Do nothing.
+ }
+
+ @Override
+ public Integer getStorageVersion() {
+ return null; // Never include these engines in any meta/global records.
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractSessionManagingSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractSessionManagingSyncStage.java
new file mode 100644
index 000000000..6592c3baa
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractSessionManagingSyncStage.java
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import org.mozilla.gecko.sync.GlobalSession;
+
+/**
+ * A global sync stage that manages a <code>GlobalSession</code> instance. This
+ * class is intended to be temporary: it should disappear as work to make
+ * data-driven syncs progresses.
+ * <p>
+ * This class is inherently <b>thread-unsafe</b>: if <code>session</code> is
+ * mutated after being set, all sorts of bad things could occur. At the time of
+ * writing, every <code>GlobalSyncStage</code> created is executed (wiped,
+ * reset) with the same <code>GlobalSession</code> argument.
+ */
+public abstract class AbstractSessionManagingSyncStage implements GlobalSyncStage {
+ protected GlobalSession session;
+
+ protected abstract void execute() throws NoSuchStageException;
+ protected abstract void resetLocal();
+ protected abstract void wipeLocal() throws Exception;
+
+ @Override
+ public void resetLocal(GlobalSession session) {
+ this.session = session;
+ resetLocal();
+ }
+
+ @Override
+ public void wipeLocal(GlobalSession session) throws Exception {
+ this.session = session;
+ wipeLocal();
+ }
+
+ @Override
+ public void execute(GlobalSession session) throws NoSuchStageException {
+ this.session = session;
+ execute();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserBookmarksServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserBookmarksServerSyncStage.java
new file mode 100644
index 000000000..10e209230
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserBookmarksServerSyncStage.java
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import java.net.URISyntaxException;
+
+import org.mozilla.gecko.sync.JSONRecordFetcher;
+import org.mozilla.gecko.sync.MetaGlobalException;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.android.AndroidBrowserBookmarksRepository;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecordFactory;
+import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
+
+public class AndroidBrowserBookmarksServerSyncStage extends ServerSyncStage {
+ protected static final String LOG_TAG = "BookmarksStage";
+
+ // Eventually this kind of sync stage will be data-driven,
+ // and all this hard-coding can go away.
+ private static final String BOOKMARKS_SORT = "index";
+ // Sanity limit. Batch and total limit are the same for now, and will be adjusted
+ // once buffer and high water mark are in place. See Bug 730142.
+ private static final long BOOKMARKS_BATCH_LIMIT = 5000;
+ private static final long BOOKMARKS_TOTAL_LIMIT = 5000;
+
+ @Override
+ protected String getCollection() {
+ return "bookmarks";
+ }
+
+ @Override
+ protected String getEngineName() {
+ return "bookmarks";
+ }
+
+ @Override
+ public Integer getStorageVersion() {
+ return VersionConstants.BOOKMARKS_ENGINE_VERSION;
+ }
+
+ @Override
+ protected Repository getRemoteRepository() throws URISyntaxException {
+ // If this is a first sync, we need to check server counts to make sure that we aren't
+ // going to screw up. SafeConstrainedServer11Repository does this. See Bug 814331.
+ AuthHeaderProvider authHeaderProvider = session.getAuthHeaderProvider();
+ final JSONRecordFetcher countsFetcher = new JSONRecordFetcher(session.config.infoCollectionCountsURL(), authHeaderProvider);
+ String collection = getCollection();
+ return new SafeConstrainedServer11Repository(
+ collection,
+ session.config.storageURL(),
+ session.getAuthHeaderProvider(),
+ session.config.infoCollections,
+ session.config.infoConfiguration,
+ BOOKMARKS_BATCH_LIMIT,
+ BOOKMARKS_TOTAL_LIMIT,
+ BOOKMARKS_SORT,
+ countsFetcher);
+ }
+
+ @Override
+ protected Repository getLocalRepository() {
+ return new AndroidBrowserBookmarksRepository();
+ }
+
+ @Override
+ protected RecordFactory getRecordFactory() {
+ return new BookmarkRecordFactory();
+ }
+
+ @Override
+ protected boolean isEnabled() throws MetaGlobalException {
+ if (session == null || session.getContext() == null) {
+ return false;
+ }
+ return super.isEnabled();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserHistoryServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserHistoryServerSyncStage.java
new file mode 100644
index 000000000..947a10898
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserHistoryServerSyncStage.java
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import java.net.URISyntaxException;
+
+import org.mozilla.gecko.sync.MetaGlobalException;
+import org.mozilla.gecko.sync.repositories.ConstrainedServer11Repository;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.android.AndroidBrowserHistoryRepository;
+import org.mozilla.gecko.sync.repositories.domain.HistoryRecordFactory;
+import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
+
+public class AndroidBrowserHistoryServerSyncStage extends ServerSyncStage {
+ protected static final String LOG_TAG = "HistoryStage";
+
+ // Eventually this kind of sync stage will be data-driven,
+ // and all this hard-coding can go away.
+ private static final String HISTORY_SORT = "index";
+ // Sanity limit. Batch and total limit are the same for now, and will be adjusted
+ // once buffer and high water mark are in place. See Bug 730142.
+ private static final long HISTORY_BATCH_LIMIT = 250;
+ private static final long HISTORY_TOTAL_LIMIT = 250;
+
+ @Override
+ protected String getCollection() {
+ return "history";
+ }
+
+ @Override
+ protected String getEngineName() {
+ return "history";
+ }
+
+ @Override
+ public Integer getStorageVersion() {
+ return VersionConstants.HISTORY_ENGINE_VERSION;
+ }
+
+ @Override
+ protected Repository getLocalRepository() {
+ return new AndroidBrowserHistoryRepository();
+ }
+
+ @Override
+ protected Repository getRemoteRepository() throws URISyntaxException {
+ String collection = getCollection();
+ return new ConstrainedServer11Repository(
+ collection,
+ session.config.storageURL(),
+ session.getAuthHeaderProvider(),
+ session.config.infoCollections,
+ session.config.infoConfiguration,
+ HISTORY_BATCH_LIMIT,
+ HISTORY_TOTAL_LIMIT,
+ HISTORY_SORT);
+ }
+
+ @Override
+ protected RecordFactory getRecordFactory() {
+ return new HistoryRecordFactory();
+ }
+
+ @Override
+ protected boolean isEnabled() throws MetaGlobalException {
+ if (session == null || session.getContext() == null) {
+ return false;
+ }
+ return super.isEnabled();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CheckPreconditionsStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CheckPreconditionsStage.java
new file mode 100644
index 000000000..b33f83ad1
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CheckPreconditionsStage.java
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+
+public class CheckPreconditionsStage extends AbstractNonRepositorySyncStage {
+ @Override
+ public void execute() throws NoSuchStageException {
+ session.advance();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CompletedStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CompletedStage.java
new file mode 100644
index 000000000..7ec776324
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/CompletedStage.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+
+
+public class CompletedStage extends AbstractNonRepositorySyncStage {
+ @Override
+ public void execute() throws NoSuchStageException {
+ // TODO: Update tracking timestamps, close connections, etc.
+ // TODO: call clean() on each Repository in the sync constellation.
+ session.completeSync();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/EnsureCrypto5KeysStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/EnsureCrypto5KeysStage.java
new file mode 100644
index 000000000..5031cf770
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/EnsureCrypto5KeysStage.java
@@ -0,0 +1,192 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import java.net.URISyntaxException;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.CollectionKeys;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.NoCollectionKeysSetException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.crypto.PersistedCrypto5Keys;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageRecordRequest;
+import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+public class EnsureCrypto5KeysStage
+extends AbstractNonRepositorySyncStage
+implements SyncStorageRequestDelegate {
+
+ private static final String LOG_TAG = "EnsureC5KeysStage";
+ private static final String CRYPTO_COLLECTION = "crypto";
+ protected boolean retrying = false;
+
+ @Override
+ public void execute() throws NoSuchStageException {
+ InfoCollections infoCollections = session.config.infoCollections;
+ if (infoCollections == null) {
+ session.abort(null, "No info/collections set in EnsureCrypto5KeysStage.");
+ return;
+ }
+
+ PersistedCrypto5Keys pck = session.config.persistedCryptoKeys();
+ long lastModified = pck.lastModified();
+ if (retrying || !infoCollections.updateNeeded(CRYPTO_COLLECTION, lastModified)) {
+ // Try to use our local collection keys for this session.
+ Logger.debug(LOG_TAG, "Trying to use persisted collection keys for this session.");
+ CollectionKeys keys = pck.keys();
+ if (keys != null) {
+ Logger.trace(LOG_TAG, "Using persisted collection keys for this session.");
+ session.config.setCollectionKeys(keys);
+ session.advance();
+ return;
+ }
+ Logger.trace(LOG_TAG, "Failed to use persisted collection keys for this session.");
+ }
+
+ // We need an update: fetch fresh keys.
+ Logger.debug(LOG_TAG, "Fetching fresh collection keys for this session.");
+ try {
+ SyncStorageRecordRequest request = new SyncStorageRecordRequest(session.wboURI(CRYPTO_COLLECTION, "keys"));
+ request.delegate = this;
+ request.get();
+ } catch (URISyntaxException e) {
+ session.abort(e, "Invalid URI.");
+ }
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return session.getAuthHeaderProvider();
+ }
+
+ @Override
+ public String ifUnmodifiedSince() {
+ // TODO: last key time!
+ return null;
+ }
+
+ protected void setAndPersist(PersistedCrypto5Keys pck, CollectionKeys keys, long timestamp) {
+ session.config.setCollectionKeys(keys);
+ pck.persistKeys(keys);
+ pck.persistLastModified(timestamp);
+ }
+
+ /**
+ * Return collections where either the individual key has changed, or if the
+ * new default key is not the same as the old default key, where the
+ * collection is using the default key.
+ */
+ protected Set<String> collectionsToUpdate(CollectionKeys oldKeys, CollectionKeys newKeys) {
+ // These keys have explicitly changed; they definitely need updating.
+ Set<String> changedKeys = new HashSet<String>(CollectionKeys.differences(oldKeys, newKeys));
+
+ boolean defaultKeyChanged = true; // Most pessimistic is to assume default key has changed.
+ KeyBundle newDefaultKeyBundle = null;
+ try {
+ KeyBundle oldDefaultKeyBundle = oldKeys.defaultKeyBundle();
+ newDefaultKeyBundle = newKeys.defaultKeyBundle();
+ defaultKeyChanged = !oldDefaultKeyBundle.equals(newDefaultKeyBundle);
+ } catch (NoCollectionKeysSetException e) {
+ Logger.warn(LOG_TAG, "NoCollectionKeysSetException in EnsureCrypto5KeysStage.", e);
+ }
+
+ if (newDefaultKeyBundle == null) {
+ Logger.trace(LOG_TAG, "New default key not provided; returning changed individual keys.");
+ return changedKeys;
+ }
+
+ if (!defaultKeyChanged) {
+ Logger.trace(LOG_TAG, "New default key is the same as old default key; returning changed individual keys.");
+ return changedKeys;
+ }
+
+ // New keys have a different default/sync key; check known collections against the default key.
+ Logger.debug(LOG_TAG, "New default key is not the same as old default key.");
+ for (Stage stage : Stage.getNamedStages()) {
+ String name = stage.getRepositoryName();
+ if (!newKeys.keyBundleForCollectionIsNotDefault(name)) {
+ // Default key has changed, so this collection has changed.
+ changedKeys.add(name);
+ }
+ }
+
+ return changedKeys;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ // Take the timestamp from the response since it is later than the timestamp from info/collections.
+ long responseTimestamp = response.normalizedWeaveTimestamp();
+ CollectionKeys keys = new CollectionKeys();
+ try {
+ ExtendedJSONObject body = response.jsonObjectBody();
+ if (Logger.LOG_PERSONAL_INFORMATION) {
+ Logger.pii(LOG_TAG, "Fetched keys: " + body.toJSONString());
+ }
+ keys.setKeyPairsFromWBO(CryptoRecord.fromJSONRecord(body), session.config.syncKeyBundle);
+ } catch (Exception e) {
+ session.abort(e, "Invalid keys WBO.");
+ return;
+ }
+
+ PersistedCrypto5Keys pck = session.config.persistedCryptoKeys();
+ if (!pck.persistedKeysExist()) {
+ // New keys, and no old keys! Persist keys and server timestamp.
+ Logger.trace(LOG_TAG, "Setting fetched keys for this session; persisting fetched keys and last modified.");
+ setAndPersist(pck, keys, responseTimestamp);
+ session.advance();
+ return;
+ }
+
+ // New keys, but we had old keys. Check for differences.
+ CollectionKeys oldKeys = pck.keys();
+ Set<String> changedCollections = collectionsToUpdate(oldKeys, keys);
+ if (!changedCollections.isEmpty()) {
+ // New keys, different from old keys.
+ Logger.trace(LOG_TAG, "Fetched keys are not the same as persisted keys; " +
+ "setting fetched keys for this session before resetting changed engines.");
+ setAndPersist(pck, keys, responseTimestamp);
+ session.resetStagesByName(changedCollections);
+ session.abort(null, "crypto/keys changed on server.");
+ return;
+ }
+
+ // New keys don't differ from old keys; persist timestamp and move on.
+ Logger.trace(LOG_TAG, "Fetched keys are the same as persisted keys; persisting only last modified.");
+ session.config.setCollectionKeys(oldKeys);
+ pck.persistLastModified(response.normalizedWeaveTimestamp());
+ session.advance();
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ if (retrying) {
+ // Should happen very rarely -- this means we uploaded our crypto/keys
+ // successfully, but failed to re-download.
+ session.handleHTTPError(response, "Failure while re-downloading already uploaded keys.");
+ return;
+ }
+
+ int statusCode = response.getStatusCode();
+ if (statusCode == 404) {
+ Logger.info(LOG_TAG, "Got 404 fetching keys. Fresh starting since keys are missing on server.");
+ session.freshStart();
+ return;
+ }
+ session.handleHTTPError(response, "Failure fetching keys: got response status code " + statusCode);
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ session.abort(ex, "Failure fetching keys.");
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FennecTabsServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FennecTabsServerSyncStage.java
new file mode 100644
index 000000000..40a474ef4
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FennecTabsServerSyncStage.java
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository;
+import org.mozilla.gecko.sync.repositories.domain.TabsRecordFactory;
+import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
+
+public class FennecTabsServerSyncStage extends ServerSyncStage {
+ private static final String COLLECTION = "tabs";
+
+ @Override
+ protected String getCollection() {
+ return COLLECTION;
+ }
+
+ @Override
+ protected String getEngineName() {
+ return COLLECTION;
+ }
+
+ @Override
+ public Integer getStorageVersion() {
+ return VersionConstants.TABS_ENGINE_VERSION;
+ }
+
+ @Override
+ protected Repository getLocalRepository() {
+ return new FennecTabsRepository(session.getClientsDelegate());
+ }
+
+ @Override
+ protected RecordFactory getRecordFactory() {
+ return new TabsRecordFactory();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoCollectionsStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoCollectionsStage.java
new file mode 100644
index 000000000..088321d5b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoCollectionsStage.java
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import java.net.URISyntaxException;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.delegates.JSONRecordFetchDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+public class FetchInfoCollectionsStage extends AbstractNonRepositorySyncStage {
+ public class StageInfoCollectionsDelegate implements JSONRecordFetchDelegate {
+
+ @Override
+ public void handleSuccess(ExtendedJSONObject global) {
+ session.config.infoCollections = new InfoCollections(global);
+ session.advance();
+ }
+
+ @Override
+ public void handleFailure(SyncStorageResponse response) {
+ session.handleHTTPError(response, "Failure fetching info/collections.");
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ session.abort(e, "Failure fetching info/collections.");
+ }
+
+ }
+
+ @Override
+ public void execute() throws NoSuchStageException {
+ try {
+ session.fetchInfoCollections(new StageInfoCollectionsDelegate());
+ } catch (URISyntaxException e) {
+ session.abort(e, "Invalid URI.");
+ }
+ }
+
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoConfigurationStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoConfigurationStage.java
new file mode 100644
index 000000000..7f53c2739
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchInfoConfigurationStage.java
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.InfoConfiguration;
+import org.mozilla.gecko.sync.JSONRecordFetcher;
+import org.mozilla.gecko.sync.delegates.JSONRecordFetchDelegate;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+/**
+ * Fetches configuration data from info/configurations endpoint.
+ */
+public class FetchInfoConfigurationStage extends AbstractNonRepositorySyncStage {
+ private final String configurationURL;
+ private final AuthHeaderProvider authHeaderProvider;
+
+ public FetchInfoConfigurationStage(final String configurationURL, final AuthHeaderProvider authHeaderProvider) {
+ super();
+ this.configurationURL = configurationURL;
+ this.authHeaderProvider = authHeaderProvider;
+ }
+
+ public class StageInfoConfigurationDelegate implements JSONRecordFetchDelegate {
+ @Override
+ public void handleSuccess(final ExtendedJSONObject result) {
+ session.config.infoConfiguration = new InfoConfiguration(result);
+ session.advance();
+ }
+
+ @Override
+ public void handleFailure(final SyncStorageResponse response) {
+ // Handle all non-404 failures upstream.
+ if (response.getStatusCode() != 404) {
+ session.handleHTTPError(response, "Failure fetching info/configuration");
+ return;
+ }
+
+ // End-point might not be available (404) if server is running an older version.
+ // We will use default config values in this case.
+ session.config.infoConfiguration = new InfoConfiguration();
+ session.advance();
+ }
+
+ @Override
+ public void handleError(final Exception e) {
+ session.abort(e, "Failure fetching info/configuration");
+ }
+ }
+ @Override
+ public void execute() {
+ final StageInfoConfigurationDelegate delegate = new StageInfoConfigurationDelegate();
+ final JSONRecordFetcher fetcher = new JSONRecordFetcher(configurationURL, authHeaderProvider);
+ fetcher.fetch(delegate);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchMetaGlobalStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchMetaGlobalStage.java
new file mode 100644
index 000000000..b4407b26b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FetchMetaGlobalStage.java
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.MetaGlobal;
+import org.mozilla.gecko.sync.PersistedMetaGlobal;
+import org.mozilla.gecko.sync.delegates.MetaGlobalDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+public class FetchMetaGlobalStage extends AbstractNonRepositorySyncStage {
+ private static final String LOG_TAG = "FetchMetaGlobalStage";
+ private static final String META_COLLECTION = "meta";
+
+ public class StageMetaGlobalDelegate implements MetaGlobalDelegate {
+
+ private final GlobalSession session;
+ public StageMetaGlobalDelegate(GlobalSession session) {
+ this.session = session;
+ }
+
+ @Override
+ public void handleSuccess(MetaGlobal global, SyncStorageResponse response) {
+ Logger.trace(LOG_TAG, "Persisting fetched meta/global and last modified.");
+ PersistedMetaGlobal pmg = session.config.persistedMetaGlobal();
+ pmg.persistMetaGlobal(global);
+ // Take the timestamp from the response since it is later than the timestamp from info/collections.
+ pmg.persistLastModified(response.normalizedWeaveTimestamp());
+
+ session.processMetaGlobal(global);
+ }
+
+ @Override
+ public void handleFailure(SyncStorageResponse response) {
+ session.handleHTTPError(response, "Failure fetching meta/global.");
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ session.abort(e, "Failure fetching meta/global.");
+ }
+
+ @Override
+ public void handleMissing(MetaGlobal global, SyncStorageResponse response) {
+ session.processMissingMetaGlobal(global);
+ }
+ }
+
+ @Override
+ public void execute() throws NoSuchStageException {
+ InfoCollections infoCollections = session.config.infoCollections;
+ if (infoCollections == null) {
+ session.abort(null, "No info/collections set in FetchMetaGlobalStage.");
+ return;
+ }
+
+ long lastModified = session.config.persistedMetaGlobal().lastModified();
+ if (!infoCollections.updateNeeded(META_COLLECTION, lastModified)) {
+ // Try to use our local collection keys for this session.
+ Logger.info(LOG_TAG, "Trying to use persisted meta/global for this session.");
+ MetaGlobal global = session.config.persistedMetaGlobal().metaGlobal(session.config.metaURL(), session.getAuthHeaderProvider());
+ if (global != null) {
+ Logger.info(LOG_TAG, "Using persisted meta/global for this session.");
+ session.processMetaGlobal(global); // Calls session.advance().
+ return;
+ }
+ Logger.info(LOG_TAG, "Failed to use persisted meta/global for this session.");
+ }
+
+ // We need an update: fetch or upload meta/global as necessary.
+ Logger.info(LOG_TAG, "Fetching fresh meta/global for this session.");
+ MetaGlobal global = new MetaGlobal(session.config.metaURL(), session.getAuthHeaderProvider());
+ global.fetch(new StageMetaGlobalDelegate(session));
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FormHistoryServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FormHistoryServerSyncStage.java
new file mode 100644
index 000000000..0a5d974b8
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FormHistoryServerSyncStage.java
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import java.net.URISyntaxException;
+
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.repositories.ConstrainedServer11Repository;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.android.FormHistoryRepositorySession;
+import org.mozilla.gecko.sync.repositories.domain.FormHistoryRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
+
+public class FormHistoryServerSyncStage extends ServerSyncStage {
+
+ // Eventually this kind of sync stage will be data-driven,
+ // and all this hard-coding can go away.
+ private static final String FORM_HISTORY_SORT = "index";
+ // Sanity limit. Batch and total limit are the same for now, and will be adjusted
+ // once buffer and high water mark are in place. See Bug 730142.
+ private static final long FORM_HISTORY_BATCH_LIMIT = 5000;
+ private static final long FORM_HISTORY_TOTAL_LIMIT = 5000;
+
+ @Override
+ protected String getCollection() {
+ return "forms";
+ }
+
+ @Override
+ protected String getEngineName() {
+ return "forms";
+ }
+
+ @Override
+ public Integer getStorageVersion() {
+ return VersionConstants.FORMS_ENGINE_VERSION;
+ }
+
+ @Override
+ protected Repository getRemoteRepository() throws URISyntaxException {
+ String collection = getCollection();
+ return new ConstrainedServer11Repository(
+ collection,
+ session.config.storageURL(),
+ session.getAuthHeaderProvider(),
+ session.config.infoCollections,
+ session.config.infoConfiguration,
+ FORM_HISTORY_BATCH_LIMIT,
+ FORM_HISTORY_TOTAL_LIMIT,
+ FORM_HISTORY_SORT);
+ }
+
+ @Override
+ protected Repository getLocalRepository() {
+ return new FormHistoryRepositorySession.FormHistoryRepository();
+ }
+
+ public class FormHistoryRecordFactory extends RecordFactory {
+
+ @Override
+ public Record createRecord(Record record) {
+ FormHistoryRecord r = new FormHistoryRecord();
+ r.initFromEnvelope((CryptoRecord) record);
+ return r;
+ }
+ }
+
+ @Override
+ protected RecordFactory getRecordFactory() {
+ return new FormHistoryRecordFactory();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/GlobalSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/GlobalSyncStage.java
new file mode 100644
index 000000000..6dee71f90
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/GlobalSyncStage.java
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.mozilla.gecko.sync.GlobalSession;
+
+
+public interface GlobalSyncStage {
+ public static enum Stage {
+ idle, // Start state.
+ checkPreconditions, // Preparation of the basics. TODO: clear status
+ fetchInfoCollections, // Take a look at timestamps.
+ fetchInfoConfiguration, // Fetch server upload limits
+ fetchMetaGlobal,
+ ensureKeysStage,
+ /*
+ ensureSpecialRecords,
+ updateEngineTimestamps,
+ */
+ syncClientsEngine(SyncClientsEngineStage.STAGE_NAME),
+ /*
+ processFirstSyncPref,
+ processClientCommands,
+ updateEnabledEngines,
+ */
+ syncTabs("tabs"),
+ syncPasswords("passwords"),
+ syncBookmarks("bookmarks"),
+ syncHistory("history"),
+ syncFormHistory("forms"),
+
+ uploadMetaGlobal,
+ completed;
+
+ // Maintain a mapping from names ("bookmarks") to Stage enumerations (syncBookmarks).
+ private static final Map<String, Stage> named = new HashMap<String, Stage>();
+ static {
+ for (Stage s : EnumSet.allOf(Stage.class)) {
+ if (s.getRepositoryName() != null) {
+ named.put(s.getRepositoryName(), s);
+ }
+ }
+ }
+
+ public static Stage byName(final String name) {
+ if (name == null) {
+ return null;
+ }
+ return named.get(name);
+ }
+
+ /**
+ * @return an immutable collection of Stages.
+ */
+ public static Collection<Stage> getNamedStages() {
+ return Collections.unmodifiableCollection(named.values());
+ }
+
+ // Each Stage tracks its repositoryName.
+ private final String repositoryName;
+ public String getRepositoryName() {
+ return repositoryName;
+ }
+
+ private Stage() {
+ this.repositoryName = null;
+ }
+
+ private Stage(final String name) {
+ this.repositoryName = name;
+ }
+ }
+
+ public void execute(GlobalSession session) throws NoSuchStageException;
+ public void resetLocal(GlobalSession session);
+ public void wipeLocal(GlobalSession session) throws Exception;
+
+ /**
+ * What storage version number this engine supports.
+ * <p>
+ * Used to generate a fresh meta/global record for upload.
+ * @return a version number or <code>null</code> to never include this engine in a fresh meta/global record.
+ */
+ public Integer getStorageVersion();
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/NoSuchStageException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/NoSuchStageException.java
new file mode 100644
index 000000000..14c9bb43e
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/NoSuchStageException.java
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+public class NoSuchStageException extends Exception {
+ private static final long serialVersionUID = 8338484472880746971L;
+ GlobalSyncStage.Stage stage;
+ public NoSuchStageException(GlobalSyncStage.Stage stage) {
+ this.stage = stage;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/PasswordsServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/PasswordsServerSyncStage.java
new file mode 100644
index 000000000..c781ce2cc
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/PasswordsServerSyncStage.java
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.android.PasswordsRepositorySession;
+import org.mozilla.gecko.sync.repositories.domain.PasswordRecordFactory;
+import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
+
+public class PasswordsServerSyncStage extends ServerSyncStage {
+ @Override
+ protected String getCollection() {
+ return "passwords";
+ }
+
+ @Override
+ protected String getEngineName() {
+ return "passwords";
+ }
+
+ @Override
+ public Integer getStorageVersion() {
+ return VersionConstants.PASSWORDS_ENGINE_VERSION;
+ }
+
+ @Override
+ protected Repository getLocalRepository() {
+ return new PasswordsRepositorySession.PasswordsRepository();
+ }
+
+ @Override
+ protected RecordFactory getRecordFactory() {
+ return new PasswordRecordFactory();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SafeConstrainedServer11Repository.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SafeConstrainedServer11Repository.java
new file mode 100644
index 000000000..733c887f0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SafeConstrainedServer11Repository.java
@@ -0,0 +1,110 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import java.net.URISyntaxException;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.InfoConfiguration;
+import org.mozilla.gecko.sync.InfoCounts;
+import org.mozilla.gecko.sync.JSONRecordFetcher;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.repositories.ConstrainedServer11Repository;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.Server11RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+
+import android.content.Context;
+
+/**
+ * This is a constrained repository -- one which fetches a limited number
+ * of records -- that additionally refuses to sync if the limit will
+ * be exceeded on a first sync by the number of records on the server.
+ *
+ * You must pass an {@link InfoCounts} instance, which will be interrogated
+ * in the event of a first sync.
+ *
+ * "First sync" means that our sync timestamp is not greater than zero.
+ */
+public class SafeConstrainedServer11Repository extends ConstrainedServer11Repository {
+
+ // This can be lazily evaluated if we need it.
+ private final JSONRecordFetcher countFetcher;
+
+ public SafeConstrainedServer11Repository(String collection,
+ String storageURL,
+ AuthHeaderProvider authHeaderProvider,
+ InfoCollections infoCollections,
+ InfoConfiguration infoConfiguration,
+ long batchLimit,
+ long totalLimit,
+ String sort,
+ JSONRecordFetcher countFetcher)
+ throws URISyntaxException {
+ super(collection, storageURL, authHeaderProvider, infoCollections, infoConfiguration,
+ batchLimit, totalLimit, sort);
+ if (countFetcher == null) {
+ throw new IllegalArgumentException("countFetcher must not be null");
+ }
+ this.countFetcher = countFetcher;
+ }
+
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ delegate.onSessionCreated(new CountCheckingServer11RepositorySession(this, this.getDefaultBatchLimit()));
+ }
+
+ public class CountCheckingServer11RepositorySession extends Server11RepositorySession {
+ private static final String LOG_TAG = "CountCheckingServer11RepositorySession";
+
+ /**
+ * The session will report no data available if this is a first sync
+ * and the server has more data available than this limit.
+ */
+ private final long fetchLimit;
+
+ public CountCheckingServer11RepositorySession(Repository repository, long fetchLimit) {
+ super(repository);
+ this.fetchLimit = fetchLimit;
+ }
+
+ @Override
+ public boolean shouldSkip() {
+ // If this is a first sync, verify that we aren't going to blow through our limit.
+ final long lastSyncTimestamp = getLastSyncTimestamp();
+ if (lastSyncTimestamp > 0) {
+ Logger.info(LOG_TAG, "Collection " + collection + " has already had a first sync: " +
+ "timestamp is " + lastSyncTimestamp + "; " +
+ "ignoring any updated counts and syncing as usual.");
+ } else {
+ Logger.info(LOG_TAG, "Collection " + collection + " is starting a first sync; checking counts.");
+
+ final InfoCounts counts;
+ try {
+ // This'll probably be the same object, but best to obey the API.
+ counts = new InfoCounts(countFetcher.fetchBlocking());
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Skipping " + collection + " until we can fetch counts.", e);
+ return true;
+ }
+
+ Integer c = counts.getCount(collection);
+ if (c == null) {
+ Logger.info(LOG_TAG, "Fetched counts does not include collection " + collection + "; syncing as usual.");
+ return false;
+ }
+
+ Logger.info(LOG_TAG, "First sync for " + collection + ": " + c + " items.");
+ if (c > fetchLimit) {
+ Logger.warn(LOG_TAG, "Too many items to sync safely. Skipping.");
+ return true;
+ }
+ }
+ return super.shouldSkip();
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/ServerSyncStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/ServerSyncStage.java
new file mode 100644
index 000000000..733e69da5
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/ServerSyncStage.java
@@ -0,0 +1,627 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import android.content.Context;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.EngineSettings;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.MetaGlobalException;
+import org.mozilla.gecko.sync.NoCollectionKeysSetException;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.SynchronizerConfiguration;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.delegates.WipeServerDelegate;
+import org.mozilla.gecko.sync.middleware.Crypto5MiddlewareRepository;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.SyncStorageRequest;
+import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+import org.mozilla.gecko.sync.repositories.Server11Repository;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
+import org.mozilla.gecko.sync.synchronizer.ServerLocalSynchronizer;
+import org.mozilla.gecko.sync.synchronizer.Synchronizer;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerDelegate;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerSession;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Fetch from a server collection into a local repository, encrypting
+ * and decrypting along the way.
+ *
+ * @author rnewman
+ *
+ */
+public abstract class ServerSyncStage extends AbstractSessionManagingSyncStage implements SynchronizerDelegate {
+
+ protected static final String LOG_TAG = "ServerSyncStage";
+
+ protected long stageStartTimestamp = -1;
+ protected long stageCompleteTimestamp = -1;
+
+ /**
+ * Override these in your subclasses.
+ *
+ * @return true if this stage should be executed.
+ * @throws MetaGlobalException
+ */
+ protected boolean isEnabled() throws MetaGlobalException {
+ EngineSettings engineSettings = null;
+ try {
+ engineSettings = getEngineSettings();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Unable to get engine settings for " + this + ": fetching config failed.", e);
+ // Fall through; null engineSettings will pass below.
+ }
+
+ // We can be disabled by the server's meta/global record, or malformed in the server's meta/global record,
+ // or by the user manually in Sync Settings.
+ // We catch the subclasses of MetaGlobalException to trigger various resets and wipes in execute().
+ boolean enabledInMetaGlobal = session.isEngineRemotelyEnabled(this.getEngineName(), engineSettings);
+
+ // Check for manual changes to engines by the user.
+ checkAndUpdateUserSelectedEngines(enabledInMetaGlobal);
+
+ // Check for changes on the server.
+ if (!enabledInMetaGlobal) {
+ Logger.debug(LOG_TAG, "Stage " + this.getEngineName() + " disabled by server meta/global.");
+ return false;
+ }
+
+ // We can also be disabled just for this sync.
+ boolean enabledThisSync = session.isEngineLocallyEnabled(this.getEngineName()); // For ServerSyncStage, stage name == engine name.
+ if (!enabledThisSync) {
+ Logger.debug(LOG_TAG, "Stage " + this.getEngineName() + " disabled just for this sync.");
+ }
+ return enabledThisSync;
+ }
+
+ /**
+ * Compares meta/global engine state to user selected engines from Sync
+ * Settings and throws an exception if they don't match and meta/global needs
+ * to be updated.
+ *
+ * @param enabledInMetaGlobal
+ * boolean of engine sync state in meta/global
+ * @throws MetaGlobalException
+ * if engine sync state has been changed in Sync Settings, with new
+ * engine sync state.
+ */
+ protected void checkAndUpdateUserSelectedEngines(boolean enabledInMetaGlobal) throws MetaGlobalException {
+ Map<String, Boolean> selectedEngines = session.config.userSelectedEngines;
+ String thisEngine = this.getEngineName();
+
+ if (selectedEngines != null && selectedEngines.containsKey(thisEngine)) {
+ boolean enabledInSelection = selectedEngines.get(thisEngine);
+ if (enabledInMetaGlobal != enabledInSelection) {
+ // Engine enable state has been changed by the user.
+ Logger.debug(LOG_TAG, "Engine state has been changed by user. Throwing exception.");
+ throw new MetaGlobalException.MetaGlobalEngineStateChangedException(enabledInSelection);
+ }
+ }
+ }
+
+ protected EngineSettings getEngineSettings() throws NonObjectJSONException, IOException {
+ Integer version = getStorageVersion();
+ if (version == null) {
+ Logger.warn(LOG_TAG, "null storage version for " + this + "; using version 0.");
+ version = 0;
+ }
+
+ SynchronizerConfiguration config = this.getConfig();
+ if (config == null) {
+ return new EngineSettings(null, version);
+ }
+ return new EngineSettings(config.syncID, version);
+ }
+
+ protected abstract String getCollection();
+ protected abstract String getEngineName();
+ protected abstract Repository getLocalRepository();
+ protected abstract RecordFactory getRecordFactory();
+
+ // Override this in subclasses.
+ protected Repository getRemoteRepository() throws URISyntaxException {
+ String collection = getCollection();
+ return new Server11Repository(collection,
+ session.config.storageURL(),
+ session.getAuthHeaderProvider(),
+ session.config.infoCollections,
+ session.config.infoConfiguration);
+ }
+
+ /**
+ * Return a Crypto5Middleware-wrapped Server11Repository.
+ *
+ * @throws NoCollectionKeysSetException
+ * @throws URISyntaxException
+ */
+ protected Repository wrappedServerRepo() throws NoCollectionKeysSetException, URISyntaxException {
+ String collection = this.getCollection();
+ KeyBundle collectionKey = session.keyBundleForCollection(collection);
+ Crypto5MiddlewareRepository cryptoRepo = new Crypto5MiddlewareRepository(getRemoteRepository(), collectionKey);
+ cryptoRepo.recordFactory = getRecordFactory();
+ return cryptoRepo;
+ }
+
+ protected String bundlePrefix() {
+ return this.getCollection() + ".";
+ }
+
+ protected SynchronizerConfiguration getConfig() throws NonObjectJSONException, IOException {
+ return new SynchronizerConfiguration(session.config.getBranch(bundlePrefix()));
+ }
+
+ protected void persistConfig(SynchronizerConfiguration synchronizerConfiguration) {
+ synchronizerConfiguration.persist(session.config.getBranch(bundlePrefix()));
+ }
+
+ public Synchronizer getConfiguredSynchronizer(GlobalSession session) throws NoCollectionKeysSetException, URISyntaxException, NonObjectJSONException, IOException {
+ Repository remote = wrappedServerRepo();
+
+ Synchronizer synchronizer = new ServerLocalSynchronizer();
+ synchronizer.repositoryA = remote;
+ synchronizer.repositoryB = this.getLocalRepository();
+ synchronizer.load(getConfig());
+
+ return synchronizer;
+ }
+
+ /**
+ * Reset timestamps.
+ */
+ @Override
+ protected void resetLocal() {
+ resetLocalWithSyncID(null);
+ }
+
+ /**
+ * Reset timestamps and possibly set syncID.
+ * @param syncID if non-null, new syncID to persist.
+ */
+ protected void resetLocalWithSyncID(String syncID) {
+ // Clear both timestamps.
+ SynchronizerConfiguration config;
+ try {
+ config = this.getConfig();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Unable to reset " + this + ": fetching config failed.", e);
+ return;
+ }
+
+ if (syncID != null) {
+ config.syncID = syncID;
+ Logger.info(LOG_TAG, "Setting syncID for " + this + " to '" + syncID + "'.");
+ }
+ config.localBundle.setTimestamp(0L);
+ config.remoteBundle.setTimestamp(0L);
+ persistConfig(config);
+ Logger.info(LOG_TAG, "Reset timestamps for " + this);
+ }
+
+ // Not thread-safe. Use with caution.
+ private class WipeWaiter {
+ public boolean sessionSucceeded = true;
+ public boolean wipeSucceeded = true;
+ public Exception error;
+
+ public void notify(Exception e, boolean sessionSucceeded) {
+ this.sessionSucceeded = sessionSucceeded;
+ this.wipeSucceeded = false;
+ this.error = e;
+ this.notify();
+ }
+ }
+
+ /**
+ * Synchronously wipe this stage by instantiating a local repository session
+ * and wiping that.
+ * <p>
+ * Logs and re-throws an exception on failure.
+ */
+ @Override
+ protected void wipeLocal() throws Exception {
+ // Reset, then clear data.
+ this.resetLocal();
+
+ final WipeWaiter monitor = new WipeWaiter();
+ final Context context = session.getContext();
+ final Repository r = this.getLocalRepository();
+
+ final Runnable doWipe = new Runnable() {
+ @Override
+ public void run() {
+ r.createSession(new RepositorySessionCreationDelegate() {
+
+ @Override
+ public void onSessionCreated(final RepositorySession session) {
+ try {
+ session.begin(new RepositorySessionBeginDelegate() {
+
+ @Override
+ public void onBeginSucceeded(final RepositorySession session) {
+ session.wipe(new RepositorySessionWipeDelegate() {
+ @Override
+ public void onWipeSucceeded() {
+ try {
+ session.finish(new RepositorySessionFinishDelegate() {
+
+ @Override
+ public void onFinishSucceeded(RepositorySession session,
+ RepositorySessionBundle bundle) {
+ // Hurrah.
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void onFinishFailed(Exception ex) {
+ // Assume that no finish => no wipe.
+ synchronized (monitor) {
+ monitor.notify(ex, true);
+ }
+ }
+
+ @Override
+ public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService executor) {
+ return this;
+ }
+ });
+ } catch (InactiveSessionException e) {
+ // Cannot happen. Call for safety.
+ synchronized (monitor) {
+ monitor.notify(e, true);
+ }
+ }
+ }
+
+ @Override
+ public void onWipeFailed(Exception ex) {
+ session.abort();
+ synchronized (monitor) {
+ monitor.notify(ex, true);
+ }
+ }
+
+ @Override
+ public RepositorySessionWipeDelegate deferredWipeDelegate(ExecutorService executor) {
+ return this;
+ }
+ });
+ }
+
+ @Override
+ public void onBeginFailed(Exception ex) {
+ session.abort();
+ synchronized (monitor) {
+ monitor.notify(ex, true);
+ }
+ }
+
+ @Override
+ public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor) {
+ return this;
+ }
+ });
+ } catch (InvalidSessionTransitionException e) {
+ session.abort();
+ synchronized (monitor) {
+ monitor.notify(e, true);
+ }
+ }
+ }
+
+ @Override
+ public void onSessionCreateFailed(Exception ex) {
+ synchronized (monitor) {
+ monitor.notify(ex, false);
+ }
+ }
+
+ @Override
+ public RepositorySessionCreationDelegate deferredCreationDelegate() {
+ return this;
+ }
+ }, context);
+ }
+ };
+
+ final Thread wiping = new Thread(doWipe);
+ synchronized (monitor) {
+ wiping.start();
+ try {
+ monitor.wait();
+ } catch (InterruptedException e) {
+ Logger.error(LOG_TAG, "Wipe interrupted.");
+ }
+ }
+
+ if (!monitor.sessionSucceeded) {
+ Logger.error(LOG_TAG, "Failed to create session for wipe.");
+ throw monitor.error;
+ }
+
+ if (!monitor.wipeSucceeded) {
+ Logger.error(LOG_TAG, "Failed to wipe session.");
+ throw monitor.error;
+ }
+
+ Logger.info(LOG_TAG, "Wiping stage complete.");
+ }
+
+ /**
+ * Asynchronously wipe collection on server.
+ */
+ protected void wipeServer(final AuthHeaderProvider authHeaderProvider, final WipeServerDelegate wipeDelegate) {
+ SyncStorageRequest request;
+
+ try {
+ request = new SyncStorageRequest(session.config.collectionURI(getCollection()));
+ } catch (URISyntaxException ex) {
+ Logger.warn(LOG_TAG, "Invalid URI in wipeServer.");
+ wipeDelegate.onWipeFailed(ex);
+ return;
+ }
+
+ request.delegate = new SyncStorageRequestDelegate() {
+
+ @Override
+ public String ifUnmodifiedSince() {
+ return null;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ BaseResource.consumeEntity(response);
+ resetLocal();
+ wipeDelegate.onWiped(response.normalizedWeaveTimestamp());
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ Logger.warn(LOG_TAG, "Got request failure " + response.getStatusCode() + " in wipeServer.");
+ // Process HTTP failures here to pick up backoffs, etc.
+ session.interpretHTTPFailure(response.httpResponse());
+ BaseResource.consumeEntity(response); // The exception thrown should not need the body of the response.
+ wipeDelegate.onWipeFailed(new HTTPFailureException(response));
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ Logger.warn(LOG_TAG, "Got exception in wipeServer.", ex);
+ wipeDelegate.onWipeFailed(ex);
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return authHeaderProvider;
+ }
+ };
+
+ request.delete();
+ }
+
+ /**
+ * Synchronously wipe the server.
+ * <p>
+ * Logs and re-throws an exception on failure.
+ */
+ public void wipeServer(final GlobalSession session) throws Exception {
+ this.session = session;
+
+ final WipeWaiter monitor = new WipeWaiter();
+
+ final Runnable doWipe = new Runnable() {
+ @Override
+ public void run() {
+ wipeServer(session.getAuthHeaderProvider(), new WipeServerDelegate() {
+ @Override
+ public void onWiped(long timestamp) {
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void onWipeFailed(Exception e) {
+ synchronized (monitor) {
+ monitor.notify(e, false);
+ }
+ }
+ });
+ }
+ };
+
+ final Thread wiping = new Thread(doWipe);
+ synchronized (monitor) {
+ wiping.start();
+ try {
+ monitor.wait();
+ } catch (InterruptedException e) {
+ Logger.error(LOG_TAG, "Server wipe interrupted.");
+ }
+ }
+
+ if (!monitor.wipeSucceeded) {
+ Logger.error(LOG_TAG, "Failed to wipe server.");
+ throw monitor.error;
+ }
+
+ Logger.info(LOG_TAG, "Wiping server complete.");
+ }
+
+ @Override
+ public void execute() throws NoSuchStageException {
+ final String name = getEngineName();
+ Logger.debug(LOG_TAG, "Starting execute for " + name);
+
+ stageStartTimestamp = System.currentTimeMillis();
+
+ try {
+ if (!this.isEnabled()) {
+ Logger.info(LOG_TAG, "Skipping stage " + name + ".");
+ session.advance();
+ return;
+ }
+ } catch (MetaGlobalException.MetaGlobalMalformedSyncIDException e) {
+ // Bad engine syncID. This should never happen. Wipe the server.
+ try {
+ session.recordForMetaGlobalUpdate(name, new EngineSettings(Utils.generateGuid(), this.getStorageVersion()));
+ Logger.info(LOG_TAG, "Wiping server because malformed engine sync ID was found in meta/global.");
+ wipeServer(session);
+ Logger.info(LOG_TAG, "Wiped server after malformed engine sync ID found in meta/global.");
+ } catch (Exception ex) {
+ session.abort(ex, "Failed to wipe server after malformed engine sync ID found in meta/global.");
+ }
+ } catch (MetaGlobalException.MetaGlobalMalformedVersionException e) {
+ // Bad engine version. This should never happen. Wipe the server.
+ try {
+ session.recordForMetaGlobalUpdate(name, new EngineSettings(Utils.generateGuid(), this.getStorageVersion()));
+ Logger.info(LOG_TAG, "Wiping server because malformed engine version was found in meta/global.");
+ wipeServer(session);
+ Logger.info(LOG_TAG, "Wiped server after malformed engine version found in meta/global.");
+ } catch (Exception ex) {
+ session.abort(ex, "Failed to wipe server after malformed engine version found in meta/global.");
+ }
+ } catch (MetaGlobalException.MetaGlobalStaleClientSyncIDException e) {
+ // Our syncID is wrong. Reset client and take the server syncID.
+ Logger.warn(LOG_TAG, "Remote engine syncID different from local engine syncID:" +
+ " resetting local engine and assuming remote engine syncID.");
+ this.resetLocalWithSyncID(e.serverSyncID);
+ } catch (MetaGlobalException.MetaGlobalEngineStateChangedException e) {
+ boolean isEnabled = e.isEnabled;
+ if (!isEnabled) {
+ // Engine has been disabled; update meta/global with engine removal for upload.
+ session.removeEngineFromMetaGlobal(name);
+ session.config.declinedEngineNames.add(name);
+ } else {
+ session.config.declinedEngineNames.remove(name);
+ // Add engine with new syncID to meta/global for upload.
+ String newSyncID = Utils.generateGuid();
+ session.recordForMetaGlobalUpdate(name, new EngineSettings(newSyncID, this.getStorageVersion()));
+ // Update SynchronizerConfiguration w/ new engine syncID.
+ this.resetLocalWithSyncID(newSyncID);
+ }
+ try {
+ // Engine sync status has changed. Wipe server.
+ Logger.warn(LOG_TAG, "Wiping server because engine sync state changed.");
+ wipeServer(session);
+ Logger.warn(LOG_TAG, "Wiped server because engine sync state changed.");
+ } catch (Exception ex) {
+ session.abort(ex, "Failed to wipe server after engine sync state changed");
+ }
+ if (!isEnabled) {
+ Logger.warn(LOG_TAG, "Stage has been disabled. Advancing to next stage.");
+ session.advance();
+ return;
+ }
+ } catch (MetaGlobalException e) {
+ session.abort(e, "Inappropriate meta/global; refusing to execute " + name + " stage.");
+ return;
+ }
+
+ Synchronizer synchronizer;
+ try {
+ synchronizer = this.getConfiguredSynchronizer(session);
+ } catch (NoCollectionKeysSetException e) {
+ session.abort(e, "No CollectionKeys.");
+ return;
+ } catch (URISyntaxException e) {
+ session.abort(e, "Invalid URI syntax for server repository.");
+ return;
+ } catch (NonObjectJSONException | IOException e) {
+ session.abort(e, "Invalid persisted JSON for config.");
+ return;
+ }
+
+ Logger.debug(LOG_TAG, "Invoking synchronizer.");
+ synchronizer.synchronize(session.getContext(), this);
+ Logger.debug(LOG_TAG, "Reached end of execute.");
+ }
+
+ /**
+ * Express the duration taken by this stage as a String, like "0.56 seconds".
+ *
+ * @return formatted string.
+ */
+ protected String getStageDurationString() {
+ return Utils.formatDuration(stageStartTimestamp, stageCompleteTimestamp);
+ }
+
+ /**
+ * We synced this engine! Persist timestamps and advance the session.
+ *
+ * @param synchronizer the <code>Synchronizer</code> that succeeded.
+ */
+ @Override
+ public void onSynchronized(Synchronizer synchronizer) {
+ stageCompleteTimestamp = System.currentTimeMillis();
+ Logger.debug(LOG_TAG, "onSynchronized.");
+
+ SynchronizerConfiguration newConfig = synchronizer.save();
+ if (newConfig != null) {
+ persistConfig(newConfig);
+ } else {
+ Logger.warn(LOG_TAG, "Didn't get configuration from synchronizer after success.");
+ }
+
+ final SynchronizerSession synchronizerSession = synchronizer.getSynchronizerSession();
+ int inboundCount = synchronizerSession.getInboundCount();
+ int outboundCount = synchronizerSession.getOutboundCount();
+ Logger.info(LOG_TAG, "Stage " + getEngineName() +
+ " received " + inboundCount + " and sent " + outboundCount +
+ " records in " + getStageDurationString() + ".");
+ Logger.info(LOG_TAG, "Advancing session.");
+ session.advance();
+ }
+
+ /**
+ * We failed to sync this engine! Do not persist timestamps (which means that
+ * the next sync will include this sync's data), but do advance the session
+ * (if we didn't get a Retry-After header).
+ *
+ * @param synchronizer the <code>Synchronizer</code> that failed.
+ */
+ @Override
+ public void onSynchronizeFailed(Synchronizer synchronizer,
+ Exception lastException, String reason) {
+ stageCompleteTimestamp = System.currentTimeMillis();
+ Logger.warn(LOG_TAG, "Synchronize failed: " + reason, lastException);
+
+ // This failure could be due to a 503 or a 401 and it could have headers.
+ // Interrogate the headers but only abort the global session if Retry-After header is set.
+ if (lastException instanceof HTTPFailureException) {
+ SyncStorageResponse response = ((HTTPFailureException)lastException).response;
+ if (response.retryAfterInSeconds() > 0) {
+ session.handleHTTPError(response, reason); // Calls session.abort().
+ return;
+ } else {
+ session.interpretHTTPFailure(response.httpResponse()); // Does not call session.abort().
+ }
+ }
+
+ Logger.info(LOG_TAG, "Advancing session even though stage failed (took " + getStageDurationString() +
+ "). Timestamps not persisted.");
+ session.advance();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java
new file mode 100644
index 000000000..04d3e7ce2
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java
@@ -0,0 +1,691 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.background.fxa.FxAccountClientException;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.sync.CommandProcessor;
+import org.mozilla.gecko.sync.CommandProcessor.Command;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.NoCollectionKeysSetException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.SyncStorageCollectionRequest;
+import org.mozilla.gecko.sync.net.SyncStorageRecordRequest;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.net.WBOCollectionRequestDelegate;
+import org.mozilla.gecko.sync.net.WBORequestDelegate;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
+import org.mozilla.gecko.sync.repositories.android.RepoUtils;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecordFactory;
+import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
+
+import ch.boye.httpclientandroidlib.HttpStatus;
+
+public class SyncClientsEngineStage extends AbstractSessionManagingSyncStage {
+ private static final String LOG_TAG = "SyncClientsEngineStage";
+
+ public static final String COLLECTION_NAME = "clients";
+ public static final String STAGE_NAME = COLLECTION_NAME;
+ public static final int CLIENTS_TTL_REFRESH = 604800000; // 7 days in milliseconds.
+ public static final int MAX_UPLOAD_FAILURE_COUNT = 5;
+ public static final long NOTIFY_TAB_SENT_TTL_SECS = TimeUnit.SECONDS.convert(1L, TimeUnit.HOURS); // 1 hour
+
+ protected final ClientRecordFactory factory = new ClientRecordFactory();
+ protected ClientUploadDelegate clientUploadDelegate;
+ protected ClientDownloadDelegate clientDownloadDelegate;
+
+ // Be sure to use this safely via getClientsDatabaseAccessor/closeDataAccessor.
+ protected ClientsDatabaseAccessor db;
+
+ protected volatile boolean shouldWipe;
+ protected volatile boolean shouldUploadLocalRecord; // Set if, e.g., we received commands or need to refresh our version.
+ protected final AtomicInteger uploadAttemptsCount = new AtomicInteger();
+ protected final List<ClientRecord> modifiedClientsToUpload = new ArrayList<ClientRecord>();
+
+ protected int getClientsCount() {
+ return getClientsDatabaseAccessor().clientsCount();
+ }
+
+ protected synchronized ClientsDatabaseAccessor getClientsDatabaseAccessor() {
+ if (db == null) {
+ db = new ClientsDatabaseAccessor(session.getContext());
+ }
+ return db;
+ }
+
+ protected synchronized void closeDataAccessor() {
+ if (db == null) {
+ return;
+ }
+ db.close();
+ db = null;
+ }
+
+ /**
+ * The following two delegates, ClientDownloadDelegate and ClientUploadDelegate
+ * are both triggered in a chain, starting when execute() calls
+ * downloadClientRecords().
+ *
+ * Client records are downloaded using a get() request. Upon success of the
+ * get() request, the local client record is uploaded.
+ *
+ * @author Marina Samuel
+ *
+ */
+ public class ClientDownloadDelegate extends WBOCollectionRequestDelegate {
+
+ // We use this on each WBO, so lift it out.
+ final ClientsDataDelegate clientsDelegate = session.getClientsDelegate();
+ boolean localAccountGUIDDownloaded = false;
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return session.getAuthHeaderProvider();
+ }
+
+ @Override
+ public String ifUnmodifiedSince() {
+ // TODO last client download time?
+ return null;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+
+ // Hang onto the server's last modified timestamp to use
+ // in X-If-Unmodified-Since for upload.
+ session.config.persistServerClientsTimestamp(response.normalizedWeaveTimestamp());
+ BaseResource.consumeEntity(response);
+
+ // Wipe the clients table if it still hasn't been wiped but needs to be.
+ wipeAndStore(null);
+
+ // If we successfully downloaded all records but ours was not one of them
+ // then reset the timestamp.
+ if (!localAccountGUIDDownloaded) {
+ Logger.info(LOG_TAG, "Local client GUID does not exist on the server. Upload timestamp will be reset.");
+ session.config.persistServerClientRecordTimestamp(0);
+ }
+ localAccountGUIDDownloaded = false;
+
+ final int clientsCount;
+ try {
+ clientsCount = getClientsCount();
+ } finally {
+ // Close the database to clear cached readableDatabase/writableDatabase
+ // after we've completed our last transaction (db.store()).
+ closeDataAccessor();
+ }
+
+ Logger.debug(LOG_TAG, "Database contains " + clientsCount + " clients.");
+ Logger.debug(LOG_TAG, "Server response asserts " + response.weaveRecords() + " records.");
+
+ // TODO: persist the response timestamp to know whether to download next time (Bug 726055).
+ clientUploadDelegate = new ClientUploadDelegate();
+ clientsDelegate.setClientsCount(clientsCount);
+
+ // If we upload remote records, checkAndUpload() will be called upon
+ // upload success in the delegate. Otherwise call checkAndUpload() now.
+ if (modifiedClientsToUpload.size() > 0) {
+ // modifiedClientsToUpload is cleared in uploadRemoteRecords, save what we need here
+ final List<String> devicesToNotify = new ArrayList<>();
+ for (ClientRecord record : modifiedClientsToUpload) {
+ if (!TextUtils.isEmpty(record.fxaDeviceId)) {
+ devicesToNotify.add(record.fxaDeviceId);
+ }
+ }
+
+ // This method is synchronous, there's no risk of notifying the clients
+ // before we actually uploaded the records
+ uploadRemoteRecords();
+
+ // Notify the clients who got their record written
+ notifyClients(devicesToNotify);
+
+ return;
+ }
+ checkAndUpload();
+ }
+
+ private void notifyClients(final List<String> devicesToNotify) {
+ final ExecutorService executor = Executors.newSingleThreadExecutor();
+ final Context context = session.getContext();
+ final Account account = FirefoxAccounts.getFirefoxAccount(context);
+ if (account == null) {
+ Log.e(LOG_TAG, "Can't notify other clients: no account");
+ return;
+ }
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+ final ExtendedJSONObject payload = createNotifyDevicesPayload();
+
+ final byte[] sessionToken;
+ try {
+ sessionToken = fxAccount.getSessionToken();
+ } catch (AndroidFxAccount.InvalidFxAState invalidFxAState) {
+ Log.e(LOG_TAG, "Could not get session token", invalidFxAState);
+ return;
+ }
+
+ // API doc : https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#post-v1accountdevicesnotify
+ final FxAccountClient fxAccountClient = new FxAccountClient20(fxAccount.getAccountServerURI(), executor);
+ fxAccountClient.notifyDevices(sessionToken, devicesToNotify, payload, NOTIFY_TAB_SENT_TTL_SECS, new FxAccountClient20.RequestDelegate<ExtendedJSONObject>() {
+ @Override
+ public void handleError(Exception e) {
+ Log.e(LOG_TAG, "Error while notifying devices", e);
+ }
+
+ @Override
+ public void handleFailure(FxAccountClientException.FxAccountClientRemoteException e) {
+ Log.e(LOG_TAG, "Error while notifying devices", e);
+ }
+
+ @Override
+ public void handleSuccess(ExtendedJSONObject result) {
+ Log.i(LOG_TAG, devicesToNotify.size() + " devices notified");
+ }
+ });
+ }
+
+ @NonNull
+ @SuppressWarnings("unchecked")
+ private ExtendedJSONObject createNotifyDevicesPayload() {
+ final ExtendedJSONObject payload = new ExtendedJSONObject();
+ payload.put("version", 1);
+ payload.put("command", "sync:collection_changed");
+ final ExtendedJSONObject data = new ExtendedJSONObject();
+ final JSONArray collections = new JSONArray();
+ collections.add("clients");
+ data.put("collections", collections);
+ payload.put("data", data);
+ return payload;
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ BaseResource.consumeEntity(response); // We don't need the response at all, and any exception handling shouldn't need the response body.
+ localAccountGUIDDownloaded = false;
+
+ try {
+ Logger.info(LOG_TAG, "Client upload failed. Aborting sync.");
+ session.abort(new HTTPFailureException(response), "Client download failed.");
+ } finally {
+ // Close the database upon failure.
+ closeDataAccessor();
+ }
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ localAccountGUIDDownloaded = false;
+ try {
+ Logger.info(LOG_TAG, "Client upload error. Aborting sync.");
+ session.abort(ex, "Failure fetching client record.");
+ } finally {
+ // Close the database upon error.
+ closeDataAccessor();
+ }
+ }
+
+ @Override
+ public void handleWBO(CryptoRecord record) {
+ ClientRecord r;
+ try {
+ r = (ClientRecord) factory.createRecord(record.decrypt());
+ if (clientsDelegate.isLocalGUID(r.guid)) {
+ Logger.info(LOG_TAG, "Local client GUID exists on server and was downloaded.");
+ localAccountGUIDDownloaded = true;
+ handleDownloadedLocalRecord(r);
+ } else {
+ // Only need to store record if it isn't our local one.
+ wipeAndStore(r);
+ addCommands(r);
+ }
+ RepoUtils.logClient(r);
+ } catch (Exception e) {
+ session.abort(e, "Exception handling client WBO.");
+ return;
+ }
+ }
+
+ @Override
+ public KeyBundle keyBundle() {
+ try {
+ return session.keyBundleForCollection(COLLECTION_NAME);
+ } catch (NoCollectionKeysSetException e) {
+ return null;
+ }
+ }
+ }
+
+ public class ClientUploadDelegate extends WBORequestDelegate {
+ protected static final String LOG_TAG = "ClientUploadDelegate";
+ public Long currentlyUploadingRecordTimestamp;
+ public boolean currentlyUploadingLocalRecord;
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return session.getAuthHeaderProvider();
+ }
+
+ private void setUploadDetails(boolean isLocalRecord) {
+ // Use the timestamp for the whole collection per Sync storage 1.1 spec.
+ currentlyUploadingRecordTimestamp = session.config.getPersistedServerClientsTimestamp();
+ currentlyUploadingLocalRecord = isLocalRecord;
+ }
+
+ @Override
+ public String ifUnmodifiedSince() {
+ Long timestampInMilliseconds = currentlyUploadingRecordTimestamp;
+
+ // It's the first upload so we don't care about X-If-Unmodified-Since.
+ if (timestampInMilliseconds <= 0) {
+ return null;
+ }
+
+ return Utils.millisecondsToDecimalSecondsString(timestampInMilliseconds);
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ Logger.debug(LOG_TAG, "Upload succeeded.");
+ uploadAttemptsCount.set(0);
+
+ // X-Weave-Timestamp is the modified time of uploaded records.
+ // Always persist this.
+ final long responseTimestamp = response.normalizedWeaveTimestamp();
+ Logger.trace(LOG_TAG, "Timestamp from header is: " + responseTimestamp);
+
+ if (responseTimestamp == -1) {
+ final String message = "Response did not contain a valid timestamp.";
+ session.abort(new RuntimeException(message), message);
+ return;
+ }
+
+ BaseResource.consumeEntity(response);
+ session.config.persistServerClientsTimestamp(responseTimestamp);
+
+ // If we're not uploading our record, we're done here; just
+ // clean up and finish.
+ if (!currentlyUploadingLocalRecord) {
+ // TODO: check failed uploads in body.
+ clearRecordsToUpload();
+ checkAndUpload();
+ return;
+ }
+
+ // If we're processing our record, we have a little more cleanup
+ // to do.
+ shouldUploadLocalRecord = false;
+ session.config.persistServerClientRecordTimestamp(responseTimestamp);
+ session.advance();
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ int statusCode = response.getStatusCode();
+
+ // If upload failed because of `ifUnmodifiedSince` then there are new
+ // commands uploaded to our record. We must download and process them first.
+ if (!shouldUploadLocalRecord ||
+ statusCode == HttpStatus.SC_PRECONDITION_FAILED ||
+ uploadAttemptsCount.incrementAndGet() > MAX_UPLOAD_FAILURE_COUNT) {
+
+ Logger.debug(LOG_TAG, "Client upload failed. Aborting sync.");
+ if (!currentlyUploadingLocalRecord) {
+ modifiedClientsToUpload.clear(); // These will be redownloaded.
+ }
+ BaseResource.consumeEntity(response); // The exception thrown should need the response body.
+ session.abort(new HTTPFailureException(response), "Client upload failed.");
+ return;
+ }
+ Logger.trace(LOG_TAG, "Retrying upload…");
+ // Preconditions:
+ // shouldUploadLocalRecord == true &&
+ // statusCode != 412 &&
+ // uploadAttemptCount < MAX_UPLOAD_FAILURE_COUNT
+ checkAndUpload();
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ Logger.info(LOG_TAG, "Client upload error. Aborting sync.");
+ session.abort(ex, "Client upload failed.");
+ }
+
+ @Override
+ public KeyBundle keyBundle() {
+ try {
+ return session.keyBundleForCollection(COLLECTION_NAME);
+ } catch (NoCollectionKeysSetException e) {
+ return null;
+ }
+ }
+ }
+
+ @Override
+ public void execute() throws NoSuchStageException {
+ // We can be disabled just for this sync.
+ boolean enabledThisSync = session.isEngineLocallyEnabled(STAGE_NAME);
+ if (!enabledThisSync) {
+ // These log messages look best when they match the messages in ServerSyncStage.
+ Logger.debug(LOG_TAG, "Stage " + STAGE_NAME + " disabled just for this sync.");
+ Logger.info(LOG_TAG, "Skipping stage " + STAGE_NAME + ".");
+ session.advance();
+ return;
+ }
+
+ if (shouldDownload()) {
+ downloadClientRecords(); // Will kick off upload, too…
+ } else {
+ // Upload if necessary.
+ }
+ }
+
+ @Override
+ protected void resetLocal() {
+ // Clear timestamps and local data.
+ session.config.persistServerClientRecordTimestamp(0L); // TODO: roll these into one.
+ session.config.persistServerClientsTimestamp(0L);
+
+ session.getClientsDelegate().setClientsCount(0);
+ try {
+ getClientsDatabaseAccessor().wipeDB();
+ } finally {
+ closeDataAccessor();
+ }
+ }
+
+ @Override
+ protected void wipeLocal() throws Exception {
+ // Nothing more to do.
+ this.resetLocal();
+ }
+
+ @Override
+ public Integer getStorageVersion() {
+ return VersionConstants.CLIENTS_ENGINE_VERSION;
+ }
+
+ protected String getLocalClientVersion() {
+ return AppConstants.MOZ_APP_VERSION;
+ }
+
+ @SuppressWarnings("unchecked")
+ protected JSONArray getLocalClientProtocols() {
+ final JSONArray protocols = new JSONArray();
+ protocols.add(ClientRecord.PROTOCOL_LEGACY_SYNC);
+ protocols.add(ClientRecord.PROTOCOL_FXA_SYNC);
+ return protocols;
+ }
+
+ protected ClientRecord newLocalClientRecord(ClientsDataDelegate delegate) {
+ final String ourGUID = delegate.getAccountGUID();
+ final String ourName = delegate.getClientName();
+
+ ClientRecord r = new ClientRecord(ourGUID);
+ r.name = ourName;
+ r.version = getLocalClientVersion();
+ r.protocols = getLocalClientProtocols();
+
+ r.os = "Android";
+ r.application = AppConstants.MOZ_APP_DISPLAYNAME;
+ r.appPackage = AppConstants.ANDROID_PACKAGE_NAME;
+ r.device = android.os.Build.MODEL;
+ r.formfactor = delegate.getFormFactor();
+
+ Context context = session.getContext();
+ final Account account = FirefoxAccounts.getFirefoxAccount(context);
+ if (account != null) {
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+ final String deviceId = fxAccount.getDeviceId();
+ if (!TextUtils.isEmpty(deviceId)) {
+ r.fxaDeviceId = deviceId;
+ }
+ }
+
+ return r;
+ }
+
+ // TODO: Bug 726055 - More considered handling of when to sync.
+ protected boolean shouldDownload() {
+ // Ask info/collections whether a download is needed.
+ return true;
+ }
+
+ protected boolean shouldUpload() {
+ if (shouldUploadLocalRecord) {
+ return true;
+ }
+
+ long lastUpload = session.config.getPersistedServerClientRecordTimestamp(); // Defaults to 0.
+ if (lastUpload == 0) {
+ return true;
+ }
+
+ if (session.getClientsDelegate().getLastModifiedTimestamp() > lastUpload) {
+ // Something's changed locally since we last uploaded.
+ return true;
+ }
+
+ // Note the opportunity for clock drift problems here.
+ // TODO: if we track download times, we can use the timestamp of most
+ // recent download response instead of the current time.
+ long now = System.currentTimeMillis();
+ long age = now - lastUpload;
+ return age >= CLIENTS_TTL_REFRESH;
+ }
+
+ protected void handleDownloadedLocalRecord(ClientRecord r) {
+ session.config.persistServerClientRecordTimestamp(r.lastModified);
+
+ if (!getLocalClientVersion().equals(r.version) ||
+ !getLocalClientProtocols().equals(r.protocols)) {
+ shouldUploadLocalRecord = true;
+ }
+ processCommands(r.commands);
+ }
+
+ protected void processCommands(JSONArray commands) {
+ if (commands == null ||
+ commands.size() == 0) {
+ return;
+ }
+
+ shouldUploadLocalRecord = true;
+ CommandProcessor processor = CommandProcessor.getProcessor();
+
+ for (Object o : commands) {
+ processor.processCommand(session, new ExtendedJSONObject((JSONObject) o));
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ protected void addCommands(ClientRecord record) throws NullCursorException {
+ Logger.trace(LOG_TAG, "Adding commands to " + record.guid);
+ List<Command> commands = db.fetchCommandsForClient(record.guid);
+
+ if (commands == null || commands.size() == 0) {
+ Logger.trace(LOG_TAG, "No commands to add.");
+ return;
+ }
+
+ for (Command command : commands) {
+ JSONObject jsonCommand = command.asJSONObject();
+ if (record.commands == null) {
+ record.commands = new JSONArray();
+ }
+ record.commands.add(jsonCommand);
+ }
+ modifiedClientsToUpload.add(record);
+ }
+
+ @SuppressWarnings("unchecked")
+ protected void uploadRemoteRecords() {
+ Logger.trace(LOG_TAG, "In uploadRemoteRecords. Uploading " + modifiedClientsToUpload.size() + " records" );
+
+ for (ClientRecord r : modifiedClientsToUpload) {
+ Logger.trace(LOG_TAG, ">> Uploading record " + r.guid + ": " + r.name);
+ }
+
+ if (modifiedClientsToUpload.size() == 1) {
+ ClientRecord record = modifiedClientsToUpload.get(0);
+ Logger.debug(LOG_TAG, "Only 1 remote record to upload.");
+ Logger.debug(LOG_TAG, "Record last modified: " + record.lastModified);
+ CryptoRecord cryptoRecord = encryptClientRecord(record);
+ if (cryptoRecord != null) {
+ clientUploadDelegate.setUploadDetails(false);
+ this.uploadClientRecord(cryptoRecord);
+ }
+ return;
+ }
+
+ JSONArray cryptoRecords = new JSONArray();
+ for (ClientRecord record : modifiedClientsToUpload) {
+ Logger.trace(LOG_TAG, "Record " + record.guid + " is being uploaded" );
+
+ CryptoRecord cryptoRecord = encryptClientRecord(record);
+ cryptoRecords.add(cryptoRecord.toJSONObject());
+ }
+ Logger.debug(LOG_TAG, "Uploading records: " + cryptoRecords.size());
+ clientUploadDelegate.setUploadDetails(false);
+ this.uploadClientRecords(cryptoRecords);
+ }
+
+ protected void checkAndUpload() {
+ if (!shouldUpload()) {
+ Logger.debug(LOG_TAG, "Not uploading client record.");
+ session.advance();
+ return;
+ }
+
+ final ClientRecord localClient = newLocalClientRecord(session.getClientsDelegate());
+ clientUploadDelegate.setUploadDetails(true);
+ CryptoRecord cryptoRecord = encryptClientRecord(localClient);
+ if (cryptoRecord != null) {
+ this.uploadClientRecord(cryptoRecord);
+ }
+ }
+
+ protected CryptoRecord encryptClientRecord(ClientRecord recordToUpload) {
+ // Generate CryptoRecord from ClientRecord to upload.
+ final String encryptionFailure = "Couldn't encrypt new client record.";
+
+ try {
+ CryptoRecord cryptoRecord = recordToUpload.getEnvelope();
+ cryptoRecord.keyBundle = clientUploadDelegate.keyBundle();
+ if (cryptoRecord.keyBundle == null) {
+ session.abort(new NoCollectionKeysSetException(), "No collection keys set.");
+ return null;
+ }
+ return cryptoRecord.encrypt();
+ } catch (UnsupportedEncodingException e) {
+ session.abort(e, encryptionFailure + " Unsupported encoding.");
+ } catch (CryptoException e) {
+ session.abort(e, encryptionFailure);
+ }
+ return null;
+ }
+
+ public void clearRecordsToUpload() {
+ try {
+ getClientsDatabaseAccessor().wipeCommandsTable();
+ modifiedClientsToUpload.clear();
+ } finally {
+ closeDataAccessor();
+ }
+ }
+
+ protected void downloadClientRecords() {
+ shouldWipe = true;
+ clientDownloadDelegate = makeClientDownloadDelegate();
+
+ try {
+ final URI getURI = session.config.collectionURI(COLLECTION_NAME, true);
+ final SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(getURI);
+ request.delegate = clientDownloadDelegate;
+
+ Logger.trace(LOG_TAG, "Downloading client records.");
+ request.get();
+ } catch (URISyntaxException e) {
+ session.abort(e, "Invalid URI.");
+ }
+ }
+
+ protected void uploadClientRecords(JSONArray records) {
+ Logger.trace(LOG_TAG, "Uploading " + records.size() + " client records.");
+ try {
+ final URI postURI = session.config.collectionURI(COLLECTION_NAME, false);
+ final SyncStorageRecordRequest request = new SyncStorageRecordRequest(postURI);
+ request.delegate = clientUploadDelegate;
+ request.post(records);
+ } catch (URISyntaxException e) {
+ session.abort(e, "Invalid URI.");
+ } catch (Exception e) {
+ session.abort(e, "Unable to parse body.");
+ }
+ }
+
+ /**
+ * Upload a client record via HTTP POST to the parent collection.
+ */
+ protected void uploadClientRecord(CryptoRecord record) {
+ Logger.debug(LOG_TAG, "Uploading client record " + record.guid);
+ try {
+ final URI postURI = session.config.collectionURI(COLLECTION_NAME);
+ final SyncStorageRecordRequest request = new SyncStorageRecordRequest(postURI);
+ request.delegate = clientUploadDelegate;
+ request.post(record);
+ } catch (URISyntaxException e) {
+ session.abort(e, "Invalid URI.");
+ }
+ }
+
+ protected ClientDownloadDelegate makeClientDownloadDelegate() {
+ return new ClientDownloadDelegate();
+ }
+
+ protected void wipeAndStore(ClientRecord record) {
+ final ClientsDatabaseAccessor db = getClientsDatabaseAccessor();
+ if (shouldWipe) {
+ db.wipeClientsTable();
+ shouldWipe = false;
+ }
+ if (record != null) {
+ db.store(record);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/UploadMetaGlobalStage.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/UploadMetaGlobalStage.java
new file mode 100644
index 000000000..77846c212
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/UploadMetaGlobalStage.java
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.stage;
+
+
+public class UploadMetaGlobalStage extends AbstractNonRepositorySyncStage {
+ public static final String LOG_TAG = "UploadMGStage";
+
+ @Override
+ public void execute() throws NoSuchStageException {
+ if (session.hasUpdatedMetaGlobal()) {
+ session.uploadUpdatedMetaGlobal();
+ }
+ session.advance();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ConcurrentRecordConsumer.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ConcurrentRecordConsumer.java
new file mode 100644
index 000000000..9b1ef3e85
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ConcurrentRecordConsumer.java
@@ -0,0 +1,122 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+/**
+ * Consume records from a queue inside a RecordsChannel, as fast as we can.
+ * TODO: rewrite this in terms of an ExecutorService and a CompletionService.
+ * See Bug 713483.
+ *
+ * @author rnewman
+ *
+ */
+class ConcurrentRecordConsumer extends RecordConsumer {
+ private static final String LOG_TAG = "CRecordConsumer";
+
+ /**
+ * When this is true and all records have been processed, the consumer
+ * will notify its delegate.
+ */
+ protected boolean allRecordsQueued = false;
+ private long counter = 0;
+
+ public ConcurrentRecordConsumer(RecordsConsumerDelegate delegate) {
+ this.delegate = delegate;
+ }
+
+ private final Object monitor = new Object();
+ @Override
+ public void doNotify() {
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void queueFilled() {
+ Logger.debug(LOG_TAG, "Queue filled.");
+ synchronized (monitor) {
+ this.allRecordsQueued = true;
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void halt() {
+ synchronized (monitor) {
+ this.stopImmediately = true;
+ monitor.notify();
+ }
+ }
+
+ private final Object countMonitor = new Object();
+ @Override
+ public void stored() {
+ Logger.trace(LOG_TAG, "Record stored. Notifying.");
+ synchronized (countMonitor) {
+ counter++;
+ }
+ }
+
+ private void consumerIsDone() {
+ Logger.debug(LOG_TAG, "Consumer is done. Processed " + counter + ((counter == 1) ? " record." : " records."));
+ delegate.consumerIsDone(!allRecordsQueued);
+ }
+
+ @Override
+ public void run() {
+ Record record;
+
+ while (true) {
+ // The queue is concurrent-safe.
+ while ((record = delegate.getQueue().poll()) != null) {
+ synchronized (monitor) {
+ Logger.trace(LOG_TAG, "run() took monitor.");
+ if (stopImmediately) {
+ Logger.debug(LOG_TAG, "Stopping immediately. Clearing queue.");
+ delegate.getQueue().clear();
+ Logger.debug(LOG_TAG, "Notifying consumer.");
+ consumerIsDone();
+ return;
+ }
+ Logger.debug(LOG_TAG, "run() dropped monitor.");
+ }
+
+ Logger.trace(LOG_TAG, "Storing record with guid " + record.guid + ".");
+ try {
+ delegate.store(record);
+ } catch (Exception e) {
+ // TODO: Bug 709371: track records that failed to apply.
+ Logger.error(LOG_TAG, "Caught error in store.", e);
+ }
+ Logger.trace(LOG_TAG, "Done with record.");
+ }
+ synchronized (monitor) {
+ Logger.trace(LOG_TAG, "run() took monitor.");
+
+ if (allRecordsQueued) {
+ Logger.debug(LOG_TAG, "Done with records and no more to come. Notifying consumerIsDone.");
+ consumerIsDone();
+ return;
+ }
+ if (stopImmediately) {
+ Logger.debug(LOG_TAG, "Done with records and told to stop immediately. Notifying consumerIsDone.");
+ consumerIsDone();
+ return;
+ }
+ try {
+ Logger.debug(LOG_TAG, "Not told to stop but no records. Waiting.");
+ monitor.wait(10000);
+ } catch (InterruptedException e) {
+ // TODO
+ }
+ Logger.trace(LOG_TAG, "run() dropped monitor.");
+ }
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordConsumer.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordConsumer.java
new file mode 100644
index 000000000..35e57d9c2
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordConsumer.java
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+public abstract class RecordConsumer implements Runnable {
+
+ public abstract void stored();
+
+ /**
+ * There are no more store items to arrive at the delegate.
+ * When you're done, take care of finishing up.
+ */
+ public abstract void queueFilled();
+ public abstract void halt();
+
+ public abstract void doNotify();
+
+ protected boolean stopImmediately = false;
+ protected RecordsConsumerDelegate delegate;
+
+ public RecordConsumer() {
+ super();
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannel.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannel.java
new file mode 100644
index 000000000..f929cdc75
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannel.java
@@ -0,0 +1,292 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ThreadPool;
+import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
+import org.mozilla.gecko.sync.repositories.NoStoreDelegateException;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.DeferredRepositorySessionBeginDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.DeferredRepositorySessionStoreDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+/**
+ * Pulls records from `source`, applying them to `sink`.
+ * Notifies its delegate of errors and completion.
+ *
+ * All stores (initiated by a fetch) must have been completed before storeDone
+ * is invoked on the sink. This is to avoid the existing stored items being
+ * considered as the total set, with onStoreCompleted being called when they're
+ * done:
+ *
+ * store(A) store(B)
+ * store(C) storeDone()
+ * store(A) finishes. Store job begins.
+ * store(C) finishes. Store job begins.
+ * storeDone() finishes.
+ * Storing of A complete.
+ * Storing of C complete.
+ * We're done! Call onStoreCompleted.
+ * store(B) finishes... uh oh.
+ *
+ * In other words, storeDone must be gated on the synchronous invocation of every store.
+ *
+ * Similarly, we require that every store callback have returned before onStoreCompleted is invoked.
+ *
+ * This whole set of guarantees should be achievable thusly:
+ *
+ * * The fetch process must run in a single thread, and invoke store()
+ * synchronously. After processing every incoming record, storeDone is called,
+ * setting a flag.
+ * If the fetch cannot be implicitly queued, it must be explicitly queued.
+ * In this implementation, we assume that fetch callbacks are strictly ordered in this way.
+ *
+ * * The store process must be (implicitly or explicitly) queued. When the
+ * queue empties, the consumer checks the storeDone flag. If it's set, and the
+ * queue is exhausted, invoke onStoreCompleted.
+ *
+ * RecordsChannel exists to enforce this ordering of operations.
+ *
+ * @author rnewman
+ *
+ */
+public class RecordsChannel implements
+ RepositorySessionFetchRecordsDelegate,
+ RepositorySessionStoreDelegate,
+ RecordsConsumerDelegate,
+ RepositorySessionBeginDelegate {
+
+ private static final String LOG_TAG = "RecordsChannel";
+ public RepositorySession source;
+ public RepositorySession sink;
+ private final RecordsChannelDelegate delegate;
+ private long fetchEnd = -1;
+
+ protected final AtomicInteger numFetched = new AtomicInteger();
+ protected final AtomicInteger numFetchFailed = new AtomicInteger();
+ protected final AtomicInteger numStored = new AtomicInteger();
+ protected final AtomicInteger numStoreFailed = new AtomicInteger();
+
+ public RecordsChannel(RepositorySession source, RepositorySession sink, RecordsChannelDelegate delegate) {
+ this.source = source;
+ this.sink = sink;
+ this.delegate = delegate;
+ }
+
+ /*
+ * We push fetched records into a queue.
+ * A separate thread is waiting for us to notify it of work to do.
+ * When we tell it to stop, it'll stop. We do that when the fetch
+ * is completed.
+ * When it stops, we tell the sink that there are no more records,
+ * and wait for the sink to tell us that storing is done.
+ * Then we notify our delegate of completion.
+ */
+ private RecordConsumer consumer;
+ private boolean waitingForQueueDone = false;
+ private final ConcurrentLinkedQueue<Record> toProcess = new ConcurrentLinkedQueue<Record>();
+
+ @Override
+ public ConcurrentLinkedQueue<Record> getQueue() {
+ return toProcess;
+ }
+
+ protected boolean isReady() {
+ return source.isActive() && sink.isActive();
+ }
+
+ /**
+ * Get the number of records fetched so far.
+ *
+ * @return number of fetches.
+ */
+ public int getFetchCount() {
+ return numFetched.get();
+ }
+
+ /**
+ * Get the number of fetch failures recorded so far.
+ *
+ * @return number of fetch failures.
+ */
+ public int getFetchFailureCount() {
+ return numFetchFailed.get();
+ }
+
+ /**
+ * Get the number of store attempts (successful or not) so far.
+ *
+ * @return number of stores attempted.
+ */
+ public int getStoreCount() {
+ return numStored.get();
+ }
+
+ /**
+ * Get the number of store failures recorded so far.
+ *
+ * @return number of store failures.
+ */
+ public int getStoreFailureCount() {
+ return numStoreFailed.get();
+ }
+
+ /**
+ * Start records flowing through the channel.
+ */
+ public void flow() {
+ if (!isReady()) {
+ RepositorySession failed = source;
+ if (source.isActive()) {
+ failed = sink;
+ }
+ this.delegate.onFlowBeginFailed(this, new SessionNotBegunException(failed));
+ return;
+ }
+
+ if (!source.dataAvailable()) {
+ Logger.info(LOG_TAG, "No data available: short-circuiting flow from source " + source);
+ long now = System.currentTimeMillis();
+ this.delegate.onFlowCompleted(this, now, now);
+ return;
+ }
+
+ sink.setStoreDelegate(this);
+ numFetched.set(0);
+ numFetchFailed.set(0);
+ numStored.set(0);
+ numStoreFailed.set(0);
+ // Start a consumer thread.
+ this.consumer = new ConcurrentRecordConsumer(this);
+ ThreadPool.run(this.consumer);
+ waitingForQueueDone = true;
+ source.fetchSince(source.getLastSyncTimestamp(), this);
+ }
+
+ /**
+ * Begin both sessions, invoking flow() when done.
+ * @throws InvalidSessionTransitionException
+ */
+ public void beginAndFlow() throws InvalidSessionTransitionException {
+ Logger.trace(LOG_TAG, "Beginning source.");
+ source.begin(this);
+ }
+
+ @Override
+ public void store(Record record) {
+ numStored.incrementAndGet();
+ try {
+ sink.store(record);
+ } catch (NoStoreDelegateException e) {
+ Logger.error(LOG_TAG, "Got NoStoreDelegateException in RecordsChannel.store(). This should not occur. Aborting.", e);
+ delegate.onFlowStoreFailed(this, e, record.guid);
+ }
+ }
+
+ @Override
+ public void onFetchFailed(Exception ex, Record record) {
+ Logger.warn(LOG_TAG, "onFetchFailed. Calling for immediate stop.", ex);
+ numFetchFailed.incrementAndGet();
+ this.consumer.halt();
+ delegate.onFlowFetchFailed(this, ex);
+ }
+
+ @Override
+ public void onFetchedRecord(Record record) {
+ numFetched.incrementAndGet();
+ this.toProcess.add(record);
+ this.consumer.doNotify();
+ }
+
+ @Override
+ public void onFetchCompleted(final long fetchEnd) {
+ Logger.trace(LOG_TAG, "onFetchCompleted. Stopping consumer once stores are done.");
+ Logger.trace(LOG_TAG, "Fetch timestamp is " + fetchEnd);
+ this.fetchEnd = fetchEnd;
+ this.consumer.queueFilled();
+ }
+
+ @Override
+ public void onRecordStoreFailed(Exception ex, String recordGuid) {
+ Logger.trace(LOG_TAG, "Failed to store record with guid " + recordGuid);
+ numStoreFailed.incrementAndGet();
+ this.consumer.stored();
+ delegate.onFlowStoreFailed(this, ex, recordGuid);
+ // TODO: abort?
+ }
+
+ @Override
+ public void onRecordStoreSucceeded(String guid) {
+ Logger.trace(LOG_TAG, "Stored record with guid " + guid);
+ this.consumer.stored();
+ }
+
+
+ @Override
+ public void consumerIsDone(boolean allRecordsQueued) {
+ Logger.trace(LOG_TAG, "Consumer is done. Are we waiting for it? " + waitingForQueueDone);
+ if (waitingForQueueDone) {
+ waitingForQueueDone = false;
+ this.sink.storeDone(); // Now we'll be waiting for onStoreCompleted.
+ }
+ }
+
+ @Override
+ public void onStoreCompleted(long storeEnd) {
+ Logger.trace(LOG_TAG, "onStoreCompleted. Notifying delegate of onFlowCompleted. " +
+ "Fetch end is " + fetchEnd + ", store end is " + storeEnd);
+ // TODO: synchronize on consumer callback?
+ delegate.onFlowCompleted(this, fetchEnd, storeEnd);
+ }
+
+ @Override
+ public void onBeginFailed(Exception ex) {
+ delegate.onFlowBeginFailed(this, ex);
+ }
+
+ @Override
+ public void onBeginSucceeded(RepositorySession session) {
+ if (session == source) {
+ Logger.trace(LOG_TAG, "Source session began. Beginning sink session.");
+ try {
+ sink.begin(this);
+ } catch (InvalidSessionTransitionException e) {
+ onBeginFailed(e);
+ return;
+ }
+ }
+ if (session == sink) {
+ Logger.trace(LOG_TAG, "Sink session began. Beginning flow.");
+ this.flow();
+ return;
+ }
+
+ // TODO: error!
+ }
+
+ @Override
+ public RepositorySessionStoreDelegate deferredStoreDelegate(final ExecutorService executor) {
+ return new DeferredRepositorySessionStoreDelegate(this, executor);
+ }
+
+ @Override
+ public RepositorySessionBeginDelegate deferredBeginDelegate(final ExecutorService executor) {
+ return new DeferredRepositorySessionBeginDelegate(this, executor);
+ }
+
+ @Override
+ public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor) {
+ // Lie outright. We know that all of our fetch methods are safe.
+ return this;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannelDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannelDelegate.java
new file mode 100644
index 000000000..8daeb7ad5
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsChannelDelegate.java
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+public interface RecordsChannelDelegate {
+ public void onFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd);
+ public void onFlowBeginFailed(RecordsChannel recordsChannel, Exception ex);
+ public void onFlowFetchFailed(RecordsChannel recordsChannel, Exception ex);
+ public void onFlowStoreFailed(RecordsChannel recordsChannel, Exception ex, String recordGuid);
+ public void onFlowFinishFailed(RecordsChannel recordsChannel, Exception ex);
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsConsumerDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsConsumerDelegate.java
new file mode 100644
index 000000000..a00abf848
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/RecordsConsumerDelegate.java
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+interface RecordsConsumerDelegate {
+ public abstract ConcurrentLinkedQueue<Record> getQueue();
+
+ /**
+ * Called when no more items will be processed.
+ * If forced is true, the consumer is terminating because it was told to halt;
+ * not all items will necessarily have been processed.
+ * If forced is false, the consumer has invoked store and received an onStoreCompleted callback.
+ * @param forced
+ */
+ public abstract void consumerIsDone(boolean forced);
+ public abstract void store(Record record);
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SerialRecordConsumer.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SerialRecordConsumer.java
new file mode 100644
index 000000000..6ee44ea2b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SerialRecordConsumer.java
@@ -0,0 +1,131 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+/**
+ * Consume records from a queue inside a RecordsChannel, storing them serially.
+ * @author rnewman
+ *
+ */
+class SerialRecordConsumer extends RecordConsumer {
+ private static final String LOG_TAG = "SerialRecordConsumer";
+ protected boolean stopEventually = false;
+ private volatile long counter = 0;
+
+ public SerialRecordConsumer(RecordsConsumerDelegate delegate) {
+ this.delegate = delegate;
+ }
+
+ private final Object monitor = new Object();
+ @Override
+ public void doNotify() {
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void queueFilled() {
+ Logger.debug(LOG_TAG, "Queue filled.");
+ synchronized (monitor) {
+ this.stopEventually = true;
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void halt() {
+ Logger.debug(LOG_TAG, "Halting.");
+ synchronized (monitor) {
+ this.stopEventually = true;
+ this.stopImmediately = true;
+ monitor.notify();
+ }
+ }
+
+ private final Object storeSerializer = new Object();
+ @Override
+ public void stored() {
+ Logger.debug(LOG_TAG, "Record stored. Notifying.");
+ synchronized (storeSerializer) {
+ Logger.debug(LOG_TAG, "stored() took storeSerializer.");
+ counter++;
+ storeSerializer.notify();
+ Logger.debug(LOG_TAG, "stored() dropped storeSerializer.");
+ }
+ }
+ private void storeSerially(Record record) {
+ Logger.debug(LOG_TAG, "New record to store.");
+ synchronized (storeSerializer) {
+ Logger.debug(LOG_TAG, "storeSerially() took storeSerializer.");
+ Logger.debug(LOG_TAG, "Storing...");
+ try {
+ this.delegate.store(record);
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception in store. Not waiting.", e);
+ return; // So we don't block for a stored() that never comes.
+ }
+ try {
+ Logger.debug(LOG_TAG, "Waiting...");
+ storeSerializer.wait();
+ } catch (InterruptedException e) {
+ // TODO
+ }
+ Logger.debug(LOG_TAG, "storeSerially() dropped storeSerializer.");
+ }
+ }
+
+ private void consumerIsDone() {
+ long counterNow = this.counter;
+ Logger.info(LOG_TAG, "Consumer is done. Processed " + counterNow + ((counterNow == 1) ? " record." : " records."));
+ delegate.consumerIsDone(stopImmediately);
+ }
+
+ @Override
+ public void run() {
+ while (true) {
+ synchronized (monitor) {
+ Logger.debug(LOG_TAG, "run() took monitor.");
+ if (stopImmediately) {
+ Logger.debug(LOG_TAG, "Stopping immediately. Clearing queue.");
+ delegate.getQueue().clear();
+ Logger.debug(LOG_TAG, "Notifying consumer.");
+ consumerIsDone();
+ return;
+ }
+ Logger.debug(LOG_TAG, "run() dropped monitor.");
+ }
+ // The queue is concurrent-safe.
+ while (!delegate.getQueue().isEmpty()) {
+ Logger.debug(LOG_TAG, "Grabbing record...");
+ Record record = delegate.getQueue().remove();
+ // Block here, allowing us to process records
+ // serially.
+ Logger.debug(LOG_TAG, "Invoking storeSerially...");
+ this.storeSerially(record);
+ Logger.debug(LOG_TAG, "Done with record.");
+ }
+ synchronized (monitor) {
+ Logger.debug(LOG_TAG, "run() took monitor.");
+
+ if (stopEventually) {
+ Logger.debug(LOG_TAG, "Done with records and told to stop. Notifying consumer.");
+ consumerIsDone();
+ return;
+ }
+ try {
+ Logger.debug(LOG_TAG, "Not told to stop but no records. Waiting.");
+ monitor.wait(10000);
+ } catch (InterruptedException e) {
+ // TODO
+ }
+ Logger.debug(LOG_TAG, "run() dropped monitor.");
+ }
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizer.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizer.java
new file mode 100644
index 000000000..ac4f48789
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizer.java
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+/**
+ * A <code>SynchronizerSession</code> designed to be used between a remote
+ * server and a local repository.
+ * <p>
+ * See <code>ServerLocalSynchronizerSession</code> for error handling details.
+ */
+public class ServerLocalSynchronizer extends Synchronizer {
+ @Override
+ public SynchronizerSession newSynchronizerSession() {
+ return new ServerLocalSynchronizerSession(this, this);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizerSession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizerSession.java
new file mode 100644
index 000000000..dc9eb01a0
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/ServerLocalSynchronizerSession.java
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.repositories.FetchFailedException;
+import org.mozilla.gecko.sync.repositories.StoreFailedException;
+
+/**
+ * A <code>SynchronizerSession</code> designed to be used between a remote
+ * server and a local repository.
+ * <p>
+ * Handles failure cases as follows (in the order they will occur during a sync):
+ * <ul>
+ * <li>Remote fetch failures abort.</li>
+ * <li>Local store failures are ignored.</li>
+ * <li>Local fetch failures abort.</li>
+ * <li>Remote store failures abort.</li>
+ * </ul>
+ */
+public class ServerLocalSynchronizerSession extends SynchronizerSession {
+ protected static final String LOG_TAG = "ServLocSynchronizerSess";
+
+ public ServerLocalSynchronizerSession(Synchronizer synchronizer, SynchronizerSessionDelegate delegate) {
+ super(synchronizer, delegate);
+ }
+
+ @Override
+ public void onFirstFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) {
+ // Fetch failures always abort.
+ int numRemoteFetchFailed = recordsChannel.getFetchFailureCount();
+ if (numRemoteFetchFailed > 0) {
+ final String message = "Got " + numRemoteFetchFailed + " failures fetching remote records!";
+ Logger.warn(LOG_TAG, message + " Aborting session.");
+ delegate.onSynchronizeFailed(this, new FetchFailedException(), message);
+ return;
+ }
+ Logger.trace(LOG_TAG, "No failures fetching remote records.");
+
+ // Local store failures are ignored.
+ int numLocalStoreFailed = recordsChannel.getStoreFailureCount();
+ if (numLocalStoreFailed > 0) {
+ final String message = "Got " + numLocalStoreFailed + " failures storing local records!";
+ Logger.warn(LOG_TAG, message + " Ignoring local store failures and continuing synchronizer session.");
+ } else {
+ Logger.trace(LOG_TAG, "No failures storing local records.");
+ }
+
+ super.onFirstFlowCompleted(recordsChannel, fetchEnd, storeEnd);
+ }
+
+ @Override
+ public void onSecondFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) {
+ // Fetch failures always abort.
+ int numLocalFetchFailed = recordsChannel.getFetchFailureCount();
+ if (numLocalFetchFailed > 0) {
+ final String message = "Got " + numLocalFetchFailed + " failures fetching local records!";
+ Logger.warn(LOG_TAG, message + " Aborting session.");
+ delegate.onSynchronizeFailed(this, new FetchFailedException(), message);
+ return;
+ }
+ Logger.trace(LOG_TAG, "No failures fetching local records.");
+
+ // Remote store failures abort!
+ int numRemoteStoreFailed = recordsChannel.getStoreFailureCount();
+ if (numRemoteStoreFailed > 0) {
+ final String message = "Got " + numRemoteStoreFailed + " failures storing remote records!";
+ Logger.warn(LOG_TAG, message + " Aborting session.");
+ delegate.onSynchronizeFailed(this, new StoreFailedException(), message);
+ return;
+ }
+ Logger.trace(LOG_TAG, "No failures storing remote records.");
+
+ super.onSecondFlowCompleted(recordsChannel, fetchEnd, storeEnd);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SessionNotBegunException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SessionNotBegunException.java
new file mode 100644
index 000000000..20c7fcd56
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SessionNotBegunException.java
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+import org.mozilla.gecko.sync.SyncException;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+
+public class SessionNotBegunException extends SyncException {
+
+ public RepositorySession failed;
+
+ public SessionNotBegunException(RepositorySession failed) {
+ this.failed = failed;
+ }
+
+ private static final long serialVersionUID = -4565241449897072841L;
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/Synchronizer.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/Synchronizer.java
new file mode 100644
index 000000000..cc15b35a9
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/Synchronizer.java
@@ -0,0 +1,105 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.SynchronizerConfiguration;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+
+import android.content.Context;
+
+/**
+ * I perform a sync.
+ *
+ * Initialize me by calling `load` with a SynchronizerConfiguration.
+ *
+ * Start synchronizing by calling `synchronize` with a SynchronizerDelegate. I
+ * provide coarse-grained feedback by calling my delegate's callback methods.
+ *
+ * I always call exactly one of my delegate's `onSynchronized` or
+ * `onSynchronizeFailed` callback methods. In addition, I call
+ * `onSynchronizeAborted` before `onSynchronizeFailed` when I encounter a fetch,
+ * store, or session error while synchronizing.
+ *
+ * After synchronizing, call `save` to get back a SynchronizerConfiguration with
+ * updated bundle information.
+ */
+public class Synchronizer implements SynchronizerSessionDelegate {
+ public static final String LOG_TAG = "SyncDelSDelegate";
+
+ protected String configSyncID; // Used to pass syncID from load() back into save().
+
+ protected SynchronizerDelegate synchronizerDelegate;
+
+ protected SynchronizerSession session = null;
+
+ public SynchronizerSession getSynchronizerSession() {
+ return session;
+ }
+
+ @Override
+ public void onInitialized(SynchronizerSession session) {
+ session.synchronize();
+ }
+
+ @Override
+ public void onSynchronized(SynchronizerSession synchronizerSession) {
+ Logger.debug(LOG_TAG, "Got onSynchronized.");
+ Logger.debug(LOG_TAG, "Notifying SynchronizerDelegate.");
+ this.synchronizerDelegate.onSynchronized(synchronizerSession.getSynchronizer());
+ }
+
+ @Override
+ public void onSynchronizeSkipped(SynchronizerSession synchronizerSession) {
+ Logger.debug(LOG_TAG, "Got onSynchronizeSkipped.");
+ Logger.debug(LOG_TAG, "Notifying SynchronizerDelegate as if on success.");
+ this.synchronizerDelegate.onSynchronized(synchronizerSession.getSynchronizer());
+ }
+
+ @Override
+ public void onSynchronizeFailed(SynchronizerSession session,
+ Exception lastException, String reason) {
+ this.synchronizerDelegate.onSynchronizeFailed(session.getSynchronizer(), lastException, reason);
+ }
+
+ public Repository repositoryA;
+ public Repository repositoryB;
+ public RepositorySessionBundle bundleA;
+ public RepositorySessionBundle bundleB;
+
+ /**
+ * Fetch a synchronizer session appropriate for this <code>Synchronizer</code>
+ */
+ protected SynchronizerSession newSynchronizerSession() {
+ return new SynchronizerSession(this, this);
+ }
+
+ /**
+ * Start synchronizing, calling delegate's callback methods.
+ */
+ public void synchronize(Context context, SynchronizerDelegate delegate) {
+ this.synchronizerDelegate = delegate;
+ this.session = newSynchronizerSession();
+ this.session.init(context, bundleA, bundleB);
+ }
+
+ public SynchronizerConfiguration save() {
+ return new SynchronizerConfiguration(configSyncID, bundleA, bundleB);
+ }
+
+ /**
+ * Set my repository session bundles from a SynchronizerConfiguration.
+ *
+ * This method is not thread-safe.
+ *
+ * @param config
+ */
+ public void load(SynchronizerConfiguration config) {
+ bundleA = config.remoteBundle;
+ bundleB = config.localBundle;
+ configSyncID = config.syncID;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerDelegate.java
new file mode 100644
index 000000000..a290188ab
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerDelegate.java
@@ -0,0 +1,10 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+public interface SynchronizerDelegate {
+ public void onSynchronized(Synchronizer synchronizer);
+ public void onSynchronizeFailed(Synchronizer synchronizer, Exception lastException, String reason);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSession.java
new file mode 100644
index 000000000..c4d244b4c
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSession.java
@@ -0,0 +1,425 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+import org.mozilla.gecko.sync.repositories.delegates.DeferrableRepositorySessionCreationDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.DeferredRepositorySessionFinishDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+
+import android.content.Context;
+
+/**
+ * I coordinate the moving parts of a sync started by
+ * {@link Synchronizer#synchronize}.
+ *
+ * I flow records twice: first from A to B, and then from B to A. I provide
+ * fine-grained feedback by calling my delegate's callback methods.
+ *
+ * Initialize me by creating me with a Synchronizer and a
+ * SynchronizerSessionDelegate. Kick things off by calling `init` with two
+ * RepositorySessionBundles, and then call `synchronize` in your `onInitialized`
+ * callback.
+ *
+ * I always call exactly one of my delegate's `onInitialized` or
+ * `onSessionError` callback methods from `init`.
+ *
+ * I call my delegate's `onSynchronizeSkipped` callback method if there is no
+ * data to be synchronized in `synchronize`.
+ *
+ * In addition, I call `onFetchError`, `onStoreError`, and `onSessionError` when
+ * I encounter a fetch, store, or session error while synchronizing.
+ *
+ * Typically my delegate will call `abort` in its error callbacks, which will
+ * call my delegate's `onSynchronizeAborted` method and halt the sync.
+ *
+ * I always call exactly one of my delegate's `onSynchronized` or
+ * `onSynchronizeFailed` callback methods if I have not seen an error.
+ */
+public class SynchronizerSession
+extends DeferrableRepositorySessionCreationDelegate
+implements RecordsChannelDelegate,
+ RepositorySessionFinishDelegate {
+
+ protected static final String LOG_TAG = "SynchronizerSession";
+ protected Synchronizer synchronizer;
+ protected SynchronizerSessionDelegate delegate;
+ protected Context context;
+
+ /*
+ * Computed during init.
+ */
+ private RepositorySession sessionA;
+ private RepositorySession sessionB;
+ private RepositorySessionBundle bundleA;
+ private RepositorySessionBundle bundleB;
+
+ // Bug 726054: just like desktop, we track our last interaction with the server,
+ // not the last record timestamp that we fetched. This ensures that we don't re-
+ // download the records we just uploaded, at the cost of skipping any records
+ // that a concurrently syncing client has uploaded.
+ private long pendingATimestamp = -1;
+ private long pendingBTimestamp = -1;
+ private long storeEndATimestamp = -1;
+ private long storeEndBTimestamp = -1;
+ private boolean flowAToBCompleted = false;
+ private boolean flowBToACompleted = false;
+
+ protected final AtomicInteger numInboundRecords = new AtomicInteger(-1);
+ protected final AtomicInteger numOutboundRecords = new AtomicInteger(-1);
+
+ /*
+ * Public API: constructor, init, synchronize.
+ */
+ public SynchronizerSession(Synchronizer synchronizer, SynchronizerSessionDelegate delegate) {
+ this.setSynchronizer(synchronizer);
+ this.delegate = delegate;
+ }
+
+ public Synchronizer getSynchronizer() {
+ return synchronizer;
+ }
+
+ public void setSynchronizer(Synchronizer synchronizer) {
+ this.synchronizer = synchronizer;
+ }
+
+ public void init(Context context, RepositorySessionBundle bundleA, RepositorySessionBundle bundleB) {
+ this.context = context;
+ this.bundleA = bundleA;
+ this.bundleB = bundleB;
+ // Begin sessionA and sessionB, call onInitialized in callbacks.
+ this.getSynchronizer().repositoryA.createSession(this, context);
+ }
+
+ /**
+ * Get the number of records fetched from the first repository (usually the
+ * server, hence inbound).
+ * <p>
+ * Valid only after first flow has completed.
+ *
+ * @return number of records, or -1 if not valid.
+ */
+ public int getInboundCount() {
+ return numInboundRecords.get();
+ }
+
+ /**
+ * Get the number of records fetched from the second repository (usually the
+ * local store, hence outbound).
+ * <p>
+ * Valid only after second flow has completed.
+ *
+ * @return number of records, or -1 if not valid.
+ */
+ public int getOutboundCount() {
+ return numOutboundRecords.get();
+ }
+
+ // These are accessed by `abort` and `synchronize`, both of which are synchronized.
+ // Guarded by `this`.
+ protected RecordsChannel channelAToB;
+ protected RecordsChannel channelBToA;
+
+ /**
+ * Please don't call this until you've been notified with onInitialized.
+ */
+ public synchronized void synchronize() {
+ numInboundRecords.set(-1);
+ numOutboundRecords.set(-1);
+
+ // First thing: decide whether we should.
+ if (sessionA.shouldSkip() ||
+ sessionB.shouldSkip()) {
+ Logger.info(LOG_TAG, "Session requested skip. Short-circuiting sync.");
+ sessionA.abort();
+ sessionB.abort();
+ this.delegate.onSynchronizeSkipped(this);
+ return;
+ }
+
+ final SynchronizerSession session = this;
+
+ // TODO: failed record handling.
+
+ // This is the *second* record channel to flow.
+ // I, SynchronizerSession, am the delegate for the *second* flow.
+ channelBToA = new RecordsChannel(this.sessionB, this.sessionA, this);
+
+ // This is the delegate for the *first* flow.
+ RecordsChannelDelegate channelAToBDelegate = new RecordsChannelDelegate() {
+ @Override
+ public void onFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) {
+ session.onFirstFlowCompleted(recordsChannel, fetchEnd, storeEnd);
+ }
+
+ @Override
+ public void onFlowBeginFailed(RecordsChannel recordsChannel, Exception ex) {
+ Logger.warn(LOG_TAG, "First RecordsChannel onFlowBeginFailed. Logging session error.", ex);
+ session.delegate.onSynchronizeFailed(session, ex, "Failed to begin first flow.");
+ }
+
+ @Override
+ public void onFlowFetchFailed(RecordsChannel recordsChannel, Exception ex) {
+ Logger.warn(LOG_TAG, "First RecordsChannel onFlowFetchFailed. Logging remote fetch error.", ex);
+ }
+
+ @Override
+ public void onFlowStoreFailed(RecordsChannel recordsChannel, Exception ex, String recordGuid) {
+ Logger.warn(LOG_TAG, "First RecordsChannel onFlowStoreFailed. Logging local store error.", ex);
+ }
+
+ @Override
+ public void onFlowFinishFailed(RecordsChannel recordsChannel, Exception ex) {
+ Logger.warn(LOG_TAG, "First RecordsChannel onFlowFinishedFailed. Logging session error.", ex);
+ session.delegate.onSynchronizeFailed(session, ex, "Failed to finish first flow.");
+ }
+ };
+
+ // This is the *first* channel to flow.
+ channelAToB = new RecordsChannel(this.sessionA, this.sessionB, channelAToBDelegate);
+
+ Logger.trace(LOG_TAG, "Starting A to B flow. Channel is " + channelAToB);
+ try {
+ channelAToB.beginAndFlow();
+ } catch (InvalidSessionTransitionException e) {
+ onFlowBeginFailed(channelAToB, e);
+ }
+ }
+
+ /**
+ * Called after the first flow completes.
+ * <p>
+ * By default, any fetch and store failures are ignored.
+ * @param recordsChannel the <code>RecordsChannel</code> (for error testing).
+ * @param fetchEnd timestamp when fetches completed.
+ * @param storeEnd timestamp when stores completed.
+ */
+ public void onFirstFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) {
+ Logger.trace(LOG_TAG, "First RecordsChannel onFlowCompleted.");
+ Logger.debug(LOG_TAG, "Fetch end is " + fetchEnd + ". Store end is " + storeEnd + ". Starting next.");
+ pendingATimestamp = fetchEnd;
+ storeEndBTimestamp = storeEnd;
+ numInboundRecords.set(recordsChannel.getFetchCount());
+ flowAToBCompleted = true;
+ channelBToA.flow();
+ }
+
+ /**
+ * Called after the second flow completes.
+ * <p>
+ * By default, any fetch and store failures are ignored.
+ * @param recordsChannel the <code>RecordsChannel</code> (for error testing).
+ * @param fetchEnd timestamp when fetches completed.
+ * @param storeEnd timestamp when stores completed.
+ */
+ public void onSecondFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) {
+ Logger.trace(LOG_TAG, "Second RecordsChannel onFlowCompleted.");
+ Logger.debug(LOG_TAG, "Fetch end is " + fetchEnd + ". Store end is " + storeEnd + ". Finishing.");
+
+ pendingBTimestamp = fetchEnd;
+ storeEndATimestamp = storeEnd;
+ numOutboundRecords.set(recordsChannel.getFetchCount());
+ flowBToACompleted = true;
+
+ // Finish the two sessions.
+ try {
+ this.sessionA.finish(this);
+ } catch (InactiveSessionException e) {
+ this.onFinishFailed(e);
+ return;
+ }
+ }
+
+ @Override
+ public void onFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) {
+ onSecondFlowCompleted(recordsChannel, fetchEnd, storeEnd);
+ }
+
+ @Override
+ public void onFlowBeginFailed(RecordsChannel recordsChannel, Exception ex) {
+ Logger.warn(LOG_TAG, "Second RecordsChannel onFlowBeginFailed. Logging session error.", ex);
+ this.delegate.onSynchronizeFailed(this, ex, "Failed to begin second flow.");
+ }
+
+ @Override
+ public void onFlowFetchFailed(RecordsChannel recordsChannel, Exception ex) {
+ Logger.warn(LOG_TAG, "Second RecordsChannel onFlowFetchFailed. Logging local fetch error.", ex);
+ }
+
+ @Override
+ public void onFlowStoreFailed(RecordsChannel recordsChannel, Exception ex, String recordGuid) {
+ Logger.warn(LOG_TAG, "Second RecordsChannel onFlowStoreFailed. Logging remote store error.", ex);
+ }
+
+ @Override
+ public void onFlowFinishFailed(RecordsChannel recordsChannel, Exception ex) {
+ Logger.warn(LOG_TAG, "Second RecordsChannel onFlowFinishedFailed. Logging session error.", ex);
+ this.delegate.onSynchronizeFailed(this, ex, "Failed to finish second flow.");
+ }
+
+ /*
+ * RepositorySessionCreationDelegate methods.
+ */
+
+ /**
+ * I could be called twice: once for sessionA and once for sessionB.
+ *
+ * I try to clean up sessionA if it is not null, since the creation of
+ * sessionB must have failed.
+ */
+ @Override
+ public void onSessionCreateFailed(Exception ex) {
+ // Attempt to finish the first session, if the second is the one that failed.
+ if (this.sessionA != null) {
+ try {
+ // We no longer need a reference to our context.
+ this.context = null;
+ this.sessionA.finish(this);
+ } catch (Exception e) {
+ // Never mind; best-effort finish.
+ }
+ }
+ // We no longer need a reference to our context.
+ this.context = null;
+ this.delegate.onSynchronizeFailed(this, ex, "Failed to create session");
+ }
+
+ /**
+ * I should be called twice: first for sessionA and second for sessionB.
+ *
+ * If I am called for sessionB, I call my delegate's `onInitialized` callback
+ * method because my repository sessions are correctly initialized.
+ */
+ // TODO: some of this "finish and clean up" code can be refactored out.
+ @Override
+ public void onSessionCreated(RepositorySession session) {
+ if (session == null ||
+ this.sessionA == session) {
+ // TODO: clean up sessionA.
+ this.delegate.onSynchronizeFailed(this, new UnexpectedSessionException(session), "Failed to create session.");
+ return;
+ }
+ if (this.sessionA == null) {
+ this.sessionA = session;
+
+ // Unbundle.
+ try {
+ this.sessionA.unbundle(this.bundleA);
+ } catch (Exception e) {
+ this.delegate.onSynchronizeFailed(this, new UnbundleError(e, sessionA), "Failed to unbundle first session.");
+ // TODO: abort
+ return;
+ }
+ this.getSynchronizer().repositoryB.createSession(this, this.context);
+ return;
+ }
+ if (this.sessionB == null) {
+ this.sessionB = session;
+ // We no longer need a reference to our context.
+ this.context = null;
+
+ // Unbundle. We unbundled sessionA when that session was created.
+ try {
+ this.sessionB.unbundle(this.bundleB);
+ } catch (Exception e) {
+ this.delegate.onSynchronizeFailed(this, new UnbundleError(e, sessionA), "Failed to unbundle second session.");
+ return;
+ }
+
+ this.delegate.onInitialized(this);
+ return;
+ }
+ // TODO: need a way to make sure we don't call any more delegate methods.
+ this.delegate.onSynchronizeFailed(this, new UnexpectedSessionException(session), "Failed to create session.");
+ }
+
+ /*
+ * RepositorySessionFinishDelegate methods.
+ */
+
+ /**
+ * I could be called twice: once for sessionA and once for sessionB.
+ *
+ * If sessionB couldn't be created, I don't fail again.
+ */
+ @Override
+ public void onFinishFailed(Exception ex) {
+ if (this.sessionB == null) {
+ // Ah, it was a problem cleaning up. Never mind.
+ Logger.warn(LOG_TAG, "Got exception cleaning up first after second session creation failed.", ex);
+ return;
+ }
+ String session = (this.sessionA == null) ? "B" : "A";
+ this.delegate.onSynchronizeFailed(this, ex, "Finish of session " + session + " failed.");
+ }
+
+ /**
+ * I should be called twice: first for sessionA and second for sessionB.
+ *
+ * If I am called for sessionA, I try to finish sessionB.
+ *
+ * If I am called for sessionB, I call my delegate's `onSynchronized` callback
+ * method because my flows should have completed.
+ */
+ @Override
+ public void onFinishSucceeded(RepositorySession session,
+ RepositorySessionBundle bundle) {
+ Logger.debug(LOG_TAG, "onFinishSucceeded. Flows? " + flowAToBCompleted + ", " + flowBToACompleted);
+
+ if (session == sessionA) {
+ if (flowAToBCompleted) {
+ Logger.debug(LOG_TAG, "onFinishSucceeded: bumping session A's timestamp to " + pendingATimestamp + " or " + storeEndATimestamp);
+ bundle.bumpTimestamp(Math.max(pendingATimestamp, storeEndATimestamp));
+ this.synchronizer.bundleA = bundle;
+ } else {
+ // Should not happen!
+ this.delegate.onSynchronizeFailed(this, new UnexpectedSessionException(sessionA), "Failed to finish first session.");
+ return;
+ }
+ if (this.sessionB != null) {
+ Logger.trace(LOG_TAG, "Finishing session B.");
+ // On to the next.
+ try {
+ this.sessionB.finish(this);
+ } catch (InactiveSessionException e) {
+ this.onFinishFailed(e);
+ return;
+ }
+ }
+ } else if (session == sessionB) {
+ if (flowBToACompleted) {
+ Logger.debug(LOG_TAG, "onFinishSucceeded: bumping session B's timestamp to " + pendingBTimestamp + " or " + storeEndBTimestamp);
+ bundle.bumpTimestamp(Math.max(pendingBTimestamp, storeEndBTimestamp));
+ this.synchronizer.bundleB = bundle;
+ Logger.trace(LOG_TAG, "Notifying delegate.onSynchronized.");
+ this.delegate.onSynchronized(this);
+ } else {
+ // Should not happen!
+ this.delegate.onSynchronizeFailed(this, new UnexpectedSessionException(sessionB), "Failed to finish second session.");
+ return;
+ }
+ } else {
+ // TODO: hurrrrrr...
+ }
+
+ if (this.sessionB == null) {
+ this.sessionA = null; // We're done.
+ }
+ }
+
+ @Override
+ public RepositorySessionFinishDelegate deferredFinishDelegate(final ExecutorService executor) {
+ return new DeferredRepositorySessionFinishDelegate(this, executor);
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSessionDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSessionDelegate.java
new file mode 100644
index 000000000..1d55274e8
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSessionDelegate.java
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+public interface SynchronizerSessionDelegate {
+ public void onInitialized(SynchronizerSession session);
+
+ public void onSynchronized(SynchronizerSession session);
+ public void onSynchronizeFailed(SynchronizerSession session, Exception lastException, String reason);
+ public void onSynchronizeSkipped(SynchronizerSession synchronizerSession);
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnbundleError.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnbundleError.java
new file mode 100644
index 000000000..fea779636
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnbundleError.java
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+import org.mozilla.gecko.sync.SyncException;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+
+public class UnbundleError extends SyncException {
+ private static final long serialVersionUID = -8709503281041697522L;
+
+ public RepositorySession failedSession;
+
+ public UnbundleError(Exception e, RepositorySession session) {
+ super(e);
+ this.failedSession = session;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnexpectedSessionException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnexpectedSessionException.java
new file mode 100644
index 000000000..0237b884b
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/UnexpectedSessionException.java
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.synchronizer;
+
+import org.mozilla.gecko.sync.SyncException;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+
+/**
+ * An exception class that indicates that a session was passed
+ * to a begin callback and wasn't expected.
+ *
+ * This shouldn't occur.
+ *
+ * @author rnewman
+ *
+ */
+public class UnexpectedSessionException extends SyncException {
+ private static final long serialVersionUID = 949010933527484721L;
+ public RepositorySession session;
+
+ public UnexpectedSessionException(RepositorySession session) {
+ this.session = session;
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryContract.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryContract.java
new file mode 100644
index 000000000..e3e134fe5
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryContract.java
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync.telemetry;
+
+public class TelemetryContract {
+ /**
+ * We are a Sync 1.1 (legacy) client, and we downloaded a migration sentinel.
+ */
+ public static final String SYNC11_MIGRATION_SENTINELS_SEEN = "FENNEC_SYNC11_MIGRATION_SENTINELS_SEEN";
+
+ /**
+ * We are a Sync 1.1 (legacy) client and we have downloaded a migration
+ * sentinel, but there was an error creating a Firefox Account from that
+ * sentinel.
+ * <p>
+ * We have logged the error and are ignoring that sentinel.
+ */
+ public static final String SYNC11_MIGRATIONS_FAILED = "FENNEC_SYNC11_MIGRATIONS_FAILED";
+
+ /**
+ * We are a Sync 1.1 (legacy) client and we have downloaded a migration
+ * sentinel, and there was no reported error creating a Firefox Account from
+ * that sentinel.
+ * <p>
+ * We have created a Firefox Account corresponding to the sentinel and have
+ * queued the existing Old Sync account for removal.
+ */
+ public static final String SYNC11_MIGRATIONS_SUCCEEDED = "FENNEC_SYNC11_MIGRATIONS_SUCCEEDED";
+
+ /**
+ * We are (now) a Sync 1.5 (Firefox Accounts-based) client that migrated from
+ * Sync 1.1. We have presented the user the "complete upgrade" notification.
+ * <p>
+ * We will offer every time a sync is triggered, including when a notification
+ * is already pending.
+ */
+ public static final String SYNC11_MIGRATION_NOTIFICATIONS_OFFERED = "FENNEC_SYNC11_MIGRATION_NOTIFICATIONS_OFFERED";
+
+ /**
+ * We are (now) a Sync 1.5 (Firefox Accounts-based) client that migrated from
+ * Sync 1.1. We have presented the user the "complete upgrade" notification
+ * and they have successfully completed the upgrade process by entering their
+ * Firefox Account credentials.
+ */
+ public static final String SYNC11_MIGRATIONS_COMPLETED = "FENNEC_SYNC11_MIGRATIONS_COMPLETED";
+
+ public static final String SYNC_STARTED = "FENNEC_SYNC_NUMBER_OF_SYNCS_STARTED";
+
+ public static final String SYNC_COMPLETED = "FENNEC_SYNC_NUMBER_OF_SYNCS_COMPLETED";
+
+ public static final String SYNC_FAILED = "FENNEC_SYNC_NUMBER_OF_SYNCS_FAILED";
+
+ public static final String SYNC_FAILED_BACKOFF = "FENNEC_SYNC_NUMBER_OF_SYNCS_FAILED_BACKOFF";
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClient.java b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClient.java
new file mode 100644
index 000000000..9ee014dcb
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClient.java
@@ -0,0 +1,330 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tokenserver;
+
+import java.io.IOException;
+import java.net.URI;
+import java.security.GeneralSecurityException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.SkewHandler;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonArrayJSONException;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.UnexpectedJSONException.BadRequiredFieldJSONException;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BaseResourceDelegate;
+import org.mozilla.gecko.sync.net.BrowserIDAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncResponse;
+import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerConditionsRequiredException;
+import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerInvalidCredentialsException;
+import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerMalformedRequestException;
+import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerMalformedResponseException;
+import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerUnknownServiceException;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.HttpHeaders;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+import ch.boye.httpclientandroidlib.message.BasicHeader;
+
+/**
+ * HTTP client for interacting with the Mozilla Services Token Server API v1.0,
+ * as documented at
+ * <a href="http://docs.services.mozilla.com/token/apis.html">http://docs.services.mozilla.com/token/apis.html</a>.
+ * <p>
+ * A token server accepts some authorization credential and returns a different
+ * authorization credential. Usually, it used to exchange a public-key
+ * authorization token that is expensive to validate for a symmetric-key
+ * authorization that is cheap to validate. For example, we might exchange a
+ * BrowserID assertion for a HAWK id and key pair.
+ */
+public class TokenServerClient {
+ protected static final String LOG_TAG = "TokenServerClient";
+
+ public static final String JSON_KEY_API_ENDPOINT = "api_endpoint";
+ public static final String JSON_KEY_CONDITION_URLS = "condition_urls";
+ public static final String JSON_KEY_DURATION = "duration";
+ public static final String JSON_KEY_ERRORS = "errors";
+ public static final String JSON_KEY_ID = "id";
+ public static final String JSON_KEY_KEY = "key";
+ public static final String JSON_KEY_UID = "uid";
+
+ public static final String HEADER_CONDITIONS_ACCEPTED = "X-Conditions-Accepted";
+ public static final String HEADER_CLIENT_STATE = "X-Client-State";
+
+ protected final Executor executor;
+ protected final URI uri;
+
+ public TokenServerClient(URI uri, Executor executor) {
+ if (uri == null) {
+ throw new IllegalArgumentException("uri must not be null");
+ }
+ if (executor == null) {
+ throw new IllegalArgumentException("executor must not be null");
+ }
+ this.uri = uri;
+ this.executor = executor;
+ }
+
+ protected void invokeHandleSuccess(final TokenServerClientDelegate delegate, final TokenServerToken token) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.handleSuccess(token);
+ }
+ });
+ }
+
+ protected void invokeHandleFailure(final TokenServerClientDelegate delegate, final TokenServerException e) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.handleFailure(e);
+ }
+ });
+ }
+
+ /**
+ * Notify the delegate that some kind of backoff header (X-Backoff,
+ * X-Weave-Backoff, Retry-After) was received and should be acted upon.
+ *
+ * This method is non-terminal, and will be followed by a separate
+ * <code>invoke*</code> call.
+ *
+ * @param delegate
+ * the delegate to inform.
+ * @param backoffSeconds
+ * the number of seconds for which the system should wait before
+ * making another token server request to this server.
+ */
+ protected void notifyBackoff(final TokenServerClientDelegate delegate, final int backoffSeconds) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.handleBackoff(backoffSeconds);
+ }
+ });
+ }
+
+ protected void invokeHandleError(final TokenServerClientDelegate delegate, final Exception e) {
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ delegate.handleError(e);
+ }
+ });
+ }
+
+ public TokenServerToken processResponse(SyncResponse res) throws TokenServerException {
+ int statusCode = res.getStatusCode();
+
+ Logger.debug(LOG_TAG, "Got token response with status code " + statusCode + ".");
+
+ // Responses should *always* be JSON, even in the case of 4xx and 5xx
+ // errors. If we don't see JSON, the server is likely very unhappy.
+ final Header contentType = res.getContentType();
+ if (contentType == null) {
+ throw new TokenServerMalformedResponseException(null, "Non-JSON response Content-Type.");
+ }
+
+ final String type = contentType.getValue();
+ if (!type.equals("application/json") &&
+ !type.startsWith("application/json;")) {
+ Logger.warn(LOG_TAG, "Got non-JSON response with Content-Type " +
+ contentType + ". Misconfigured server?");
+ throw new TokenServerMalformedResponseException(null, "Non-JSON response Content-Type.");
+ }
+
+ // Responses should *always* be a valid JSON object.
+ // It turns out that right now they're not always, but that's a server bug...
+ ExtendedJSONObject result;
+ try {
+ result = res.jsonObjectBody();
+ } catch (Exception e) {
+ Logger.debug(LOG_TAG, "Malformed token response.", e);
+ throw new TokenServerMalformedResponseException(null, e);
+ }
+
+ // The service shouldn't have any 3xx, so we don't need to handle those.
+ if (res.getStatusCode() != 200) {
+ // We should have a (Cornice) error report in the JSON. We log that to
+ // help with debugging.
+ List<ExtendedJSONObject> errorList = new ArrayList<ExtendedJSONObject>();
+
+ if (result.containsKey(JSON_KEY_ERRORS)) {
+ try {
+ for (Object error : result.getArray(JSON_KEY_ERRORS)) {
+ Logger.warn(LOG_TAG, "" + error);
+
+ if (error instanceof JSONObject) {
+ errorList.add(new ExtendedJSONObject((JSONObject) error));
+ }
+ }
+ } catch (NonArrayJSONException e) {
+ Logger.warn(LOG_TAG, "Got non-JSON array '" + JSON_KEY_ERRORS + "'.", e);
+ }
+ }
+
+ if (statusCode == 400) {
+ throw new TokenServerMalformedRequestException(errorList, result.toJSONString());
+ }
+
+ if (statusCode == 401) {
+ throw new TokenServerInvalidCredentialsException(errorList, result.toJSONString());
+ }
+
+ // 403 should represent a "condition acceptance needed" response.
+ //
+ // The extra validation of "urls" is important. We don't want to signal
+ // conditions required unless we are absolutely sure that is what the
+ // server is asking for.
+ if (statusCode == 403) {
+ // Bug 792674 and Bug 783598: make this testing simpler. For now, we
+ // check that errors is an array, and take any condition_urls from the
+ // first element.
+
+ try {
+ if (errorList == null || errorList.isEmpty()) {
+ throw new TokenServerMalformedResponseException(errorList, "403 response without proper fields.");
+ }
+
+ ExtendedJSONObject error = errorList.get(0);
+
+ ExtendedJSONObject condition_urls = error.getObject(JSON_KEY_CONDITION_URLS);
+ if (condition_urls != null) {
+ throw new TokenServerConditionsRequiredException(condition_urls);
+ }
+ } catch (NonObjectJSONException e) {
+ Logger.warn(LOG_TAG, "Got non-JSON error object.");
+ }
+
+ throw new TokenServerMalformedResponseException(errorList, "403 response without proper fields.");
+ }
+
+ if (statusCode == 404) {
+ throw new TokenServerUnknownServiceException(errorList);
+ }
+
+ // We shouldn't ever get here...
+ throw new TokenServerException(errorList);
+ }
+
+ try {
+ result.throwIfFieldsMissingOrMisTyped(new String[] { JSON_KEY_ID, JSON_KEY_KEY, JSON_KEY_API_ENDPOINT }, String.class);
+ result.throwIfFieldsMissingOrMisTyped(new String[] { JSON_KEY_UID }, Long.class);
+ } catch (BadRequiredFieldJSONException e ) {
+ throw new TokenServerMalformedResponseException(null, e);
+ }
+
+ Logger.debug(LOG_TAG, "Successful token response: " + result.getString(JSON_KEY_ID));
+
+ return new TokenServerToken(result.getString(JSON_KEY_ID),
+ result.getString(JSON_KEY_KEY),
+ result.get(JSON_KEY_UID).toString(),
+ result.getString(JSON_KEY_API_ENDPOINT));
+ }
+
+ public static class TokenFetchResourceDelegate extends BaseResourceDelegate {
+ private final TokenServerClient client;
+ private final TokenServerClientDelegate delegate;
+ private final String assertion;
+ private final String clientState;
+ private final BaseResource resource;
+ private final boolean conditionsAccepted;
+
+ public TokenFetchResourceDelegate(TokenServerClient client,
+ BaseResource resource,
+ TokenServerClientDelegate delegate,
+ String assertion, String clientState,
+ boolean conditionsAccepted) {
+ super(resource);
+ this.client = client;
+ this.delegate = delegate;
+ this.assertion = assertion;
+ this.clientState = clientState;
+ this.resource = resource;
+ this.conditionsAccepted = conditionsAccepted;
+ }
+
+ @Override
+ public String getUserAgent() {
+ return delegate.getUserAgent();
+ }
+
+ @Override
+ public void handleHttpResponse(HttpResponse response) {
+ // Skew.
+ SkewHandler skewHandler = SkewHandler.getSkewHandlerForResource(resource);
+ skewHandler.updateSkew(response, System.currentTimeMillis());
+
+ // Extract backoff regardless of whether this was an error response, and
+ // Retry-After for 503 responses. The error will be handled elsewhere.)
+ SyncResponse res = new SyncResponse(response);
+ final boolean includeRetryAfter = res.getStatusCode() == 503;
+ int backoffInSeconds = res.totalBackoffInSeconds(includeRetryAfter);
+ if (backoffInSeconds > -1) {
+ client.notifyBackoff(delegate, backoffInSeconds);
+ }
+
+ try {
+ TokenServerToken token = client.processResponse(res);
+ client.invokeHandleSuccess(delegate, token);
+ } catch (TokenServerException e) {
+ client.invokeHandleFailure(delegate, e);
+ }
+ }
+
+ @Override
+ public void handleTransportException(GeneralSecurityException e) {
+ client.invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public void handleHttpProtocolException(ClientProtocolException e) {
+ client.invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public void handleHttpIOException(IOException e) {
+ client.invokeHandleError(delegate, e);
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return new BrowserIDAuthHeaderProvider(assertion);
+ }
+
+ @Override
+ public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+ String host = request.getURI().getHost();
+ request.setHeader(new BasicHeader(HttpHeaders.HOST, host));
+ if (clientState != null) {
+ request.setHeader(new BasicHeader(HEADER_CLIENT_STATE, clientState));
+ }
+ if (conditionsAccepted) {
+ request.addHeader(HEADER_CONDITIONS_ACCEPTED, "1");
+ }
+ }
+ }
+
+ public void getTokenFromBrowserIDAssertion(final String assertion,
+ final boolean conditionsAccepted,
+ final String clientState,
+ final TokenServerClientDelegate delegate) {
+ final BaseResource resource = new BaseResource(this.uri);
+ resource.delegate = new TokenFetchResourceDelegate(this, resource, delegate,
+ assertion, clientState,
+ conditionsAccepted);
+ resource.get();
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClientDelegate.java b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClientDelegate.java
new file mode 100644
index 000000000..e1dfe2422
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClientDelegate.java
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tokenserver;
+
+
+public interface TokenServerClientDelegate {
+ void handleSuccess(TokenServerToken token);
+ void handleFailure(TokenServerException e);
+ void handleError(Exception e);
+
+ /**
+ * Might be called multiple times, in addition to the other terminating handler methods.
+ */
+ void handleBackoff(int backoffSeconds);
+
+ public String getUserAgent();
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerException.java b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerException.java
new file mode 100644
index 000000000..099e51867
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerException.java
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tokenserver;
+
+import java.util.List;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+public class TokenServerException extends Exception {
+ private static final long serialVersionUID = 7185692034925819696L;
+
+ public final List<ExtendedJSONObject> errors;
+
+ public TokenServerException(List<ExtendedJSONObject> errors) {
+ super();
+ this.errors = errors;
+ }
+
+ public TokenServerException(List<ExtendedJSONObject> errors, String string) {
+ super(string);
+ this.errors = errors;
+ }
+
+ public TokenServerException(List<ExtendedJSONObject> errors, Throwable e) {
+ super(e);
+ this.errors = errors;
+ }
+
+ public static class TokenServerConditionsRequiredException extends TokenServerException {
+ private static final long serialVersionUID = 7578072663150608399L;
+
+ public final ExtendedJSONObject conditionUrls;
+
+ public TokenServerConditionsRequiredException(ExtendedJSONObject urls) {
+ super(null);
+ this.conditionUrls = urls;
+ }
+ }
+
+ public static class TokenServerInvalidCredentialsException extends TokenServerException {
+ private static final long serialVersionUID = 7578072663150608398L;
+
+ public TokenServerInvalidCredentialsException(List<ExtendedJSONObject> errors) {
+ super(errors);
+ }
+
+ public TokenServerInvalidCredentialsException(List<ExtendedJSONObject> errors, String message) {
+ super(errors, message);
+ }
+ }
+
+ public static class TokenServerUnknownServiceException extends TokenServerException {
+ private static final long serialVersionUID = 7578072663150608397L;
+
+ public TokenServerUnknownServiceException(List<ExtendedJSONObject> errors) {
+ super(errors);
+ }
+
+ public TokenServerUnknownServiceException(List<ExtendedJSONObject> errors, String message) {
+ super(errors, message);
+ }
+ }
+
+ public static class TokenServerMalformedRequestException extends TokenServerException {
+ private static final long serialVersionUID = 7578072663150608396L;
+
+ public TokenServerMalformedRequestException(List<ExtendedJSONObject> errors) {
+ super(errors);
+ }
+
+ public TokenServerMalformedRequestException(List<ExtendedJSONObject> errors, String message) {
+ super(errors, message);
+ }
+ }
+
+ public static class TokenServerMalformedResponseException extends TokenServerException {
+ private static final long serialVersionUID = 7578072663150608395L;
+
+ public TokenServerMalformedResponseException(List<ExtendedJSONObject> errors, String message) {
+ super(errors, message);
+ }
+
+ public TokenServerMalformedResponseException(List<ExtendedJSONObject> errors, Throwable e) {
+ super(errors, e);
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerToken.java b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerToken.java
new file mode 100644
index 000000000..916586cdc
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerToken.java
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tokenserver;
+
+public class TokenServerToken {
+ public final String id;
+ public final String key;
+ public final String uid;
+ public final String endpoint;
+
+ public TokenServerToken(String id, String key, String uid, String endpoint) {
+ this.id = id;
+ this.key = key;
+ this.uid = uid;
+ this.endpoint = endpoint;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/util/PRNGFixes.java b/mobile/android/services/src/main/java/org/mozilla/gecko/util/PRNGFixes.java
new file mode 100644
index 000000000..ebb50f765
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/util/PRNGFixes.java
@@ -0,0 +1,339 @@
+/*
+ * This software is provided 'as-is', without any express or implied
+ * warranty. In no event will Google be held liable for any damages
+ * arising from the use of this software.
+ *
+ * Permission is granted to anyone to use this software for any purpose,
+ * including commercial applications, and to alter it and redistribute it
+ * freely, as long as the origin is not misrepresented.
+ */
+
+package org.mozilla.gecko.util;
+
+import android.os.Build;
+import android.os.Process;
+import android.util.Log;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.security.NoSuchAlgorithmException;
+import java.security.Provider;
+import java.security.SecureRandom;
+import java.security.SecureRandomSpi;
+import java.security.Security;
+
+/**
+ * Fixes for the output of the default PRNG having low entropy.
+ *
+ * The fixes need to be applied via {@link #apply()} before any use of Java
+ * Cryptography Architecture primitives. A good place to invoke them is in the
+ * application's {@code onCreate}.
+ */
+public final class PRNGFixes {
+ private static final long serialVersionUID = -687331492884005033L;
+
+ private static final int VERSION_CODE_JELLY_BEAN = 16;
+ private static final int VERSION_CODE_JELLY_BEAN_MR2 = 18;
+ private static final byte[] BUILD_FINGERPRINT_AND_DEVICE_SERIAL =
+ getBuildFingerprintAndDeviceSerial();
+
+ /** Hidden constructor to prevent instantiation. */
+ private PRNGFixes() {}
+
+ /**
+ * Applies all fixes.
+ *
+ * @throws SecurityException if a fix is needed but could not be applied.
+ */
+ public static void apply() {
+ applyOpenSSLFix();
+ installLinuxPRNGSecureRandom();
+ }
+
+ /**
+ * Applies the fix for OpenSSL PRNG having low entropy. Does nothing if the
+ * fix is not needed.
+ *
+ * @throws SecurityException if the fix is needed but could not be applied.
+ */
+ private static void applyOpenSSLFix() throws SecurityException {
+ if ((Build.VERSION.SDK_INT < VERSION_CODE_JELLY_BEAN)
+ || (Build.VERSION.SDK_INT > VERSION_CODE_JELLY_BEAN_MR2)) {
+ // No need to apply the fix
+ return;
+ }
+
+ try {
+ // Mix in the device- and invocation-specific seed.
+ Class.forName("org.apache.harmony.xnet.provider.jsse.NativeCrypto")
+ .getMethod("RAND_seed", byte[].class)
+ .invoke(null, generateSeed());
+
+ // Mix output of Linux PRNG into OpenSSL's PRNG
+ int bytesRead = (Integer) Class.forName(
+ "org.apache.harmony.xnet.provider.jsse.NativeCrypto")
+ .getMethod("RAND_load_file", String.class, long.class)
+ .invoke(null, "/dev/urandom", 1024);
+ if (bytesRead != 1024) {
+ throw new IOException(
+ "Unexpected number of bytes read from Linux PRNG: "
+ + bytesRead);
+ }
+ } catch (Exception e) {
+ throw new SecurityException("Failed to seed OpenSSL PRNG", e);
+ }
+ }
+
+ /**
+ * Installs a Linux PRNG-backed {@code SecureRandom} implementation as the
+ * default. Does nothing if the implementation is already the default or if
+ * there is not need to install the implementation.
+ *
+ * @throws SecurityException if the fix is needed but could not be applied.
+ */
+ private static void installLinuxPRNGSecureRandom()
+ throws SecurityException {
+ if (Build.VERSION.SDK_INT > VERSION_CODE_JELLY_BEAN_MR2) {
+ // No need to apply the fix
+ return;
+ }
+
+ // Install a Linux PRNG-based SecureRandom implementation as the
+ // default, if not yet installed.
+ Provider[] secureRandomProviders =
+ Security.getProviders("SecureRandom.SHA1PRNG");
+ if ((secureRandomProviders == null)
+ || (secureRandomProviders.length < 1)
+ || (!LinuxPRNGSecureRandomProvider.class.equals(
+ secureRandomProviders[0].getClass()))) {
+ Security.insertProviderAt(new LinuxPRNGSecureRandomProvider(), 1);
+ }
+
+ // Assert that new SecureRandom() and
+ // SecureRandom.getInstance("SHA1PRNG") return a SecureRandom backed
+ // by the Linux PRNG-based SecureRandom implementation.
+ SecureRandom rng1 = new SecureRandom();
+ if (!LinuxPRNGSecureRandomProvider.class.equals(
+ rng1.getProvider().getClass())) {
+ throw new SecurityException(
+ "new SecureRandom() backed by wrong Provider: "
+ + rng1.getProvider().getClass());
+ }
+
+ SecureRandom rng2;
+ try {
+ rng2 = SecureRandom.getInstance("SHA1PRNG");
+ } catch (NoSuchAlgorithmException e) {
+ throw new SecurityException("SHA1PRNG not available", e);
+ }
+ if (!LinuxPRNGSecureRandomProvider.class.equals(
+ rng2.getProvider().getClass())) {
+ throw new SecurityException(
+ "SecureRandom.getInstance(\"SHA1PRNG\") backed by wrong"
+ + " Provider: " + rng2.getProvider().getClass());
+ }
+ }
+
+ /**
+ * {@code Provider} of {@code SecureRandom} engines which pass through
+ * all requests to the Linux PRNG.
+ */
+ private static class LinuxPRNGSecureRandomProvider extends Provider {
+ private static final long serialVersionUID = -686731492884005033L;
+
+ public LinuxPRNGSecureRandomProvider() {
+ super("LinuxPRNG",
+ 1.0,
+ "A Linux-specific random number provider that uses"
+ + " /dev/urandom");
+ // Although /dev/urandom is not a SHA-1 PRNG, some apps
+ // explicitly request a SHA1PRNG SecureRandom and we thus need to
+ // prevent them from getting the default implementation whose output
+ // may have low entropy.
+ put("SecureRandom.SHA1PRNG", LinuxPRNGSecureRandom.class.getName());
+ put("SecureRandom.SHA1PRNG ImplementedIn", "Software");
+ }
+ }
+
+ /**
+ * {@link SecureRandomSpi} which passes all requests to the Linux PRNG
+ * ({@code /dev/urandom}).
+ */
+ public static class LinuxPRNGSecureRandom extends SecureRandomSpi {
+ private static final long serialVersionUID = -696231492884005033L;
+
+ /*
+ * IMPLEMENTATION NOTE: Requests to generate bytes and to mix in a seed
+ * are passed through to the Linux PRNG (/dev/urandom). Instances of
+ * this class seed themselves by mixing in the current time, PID, UID,
+ * build fingerprint, and hardware serial number (where available) into
+ * Linux PRNG.
+ *
+ * Concurrency: Read requests to the underlying Linux PRNG are
+ * serialized (on sLock) to ensure that multiple threads do not get
+ * duplicated PRNG output.
+ */
+
+ private static final File URANDOM_FILE = new File("/dev/urandom");
+
+ private static final Object sLock = new Object();
+
+ /**
+ * Input stream for reading from Linux PRNG or {@code null} if not yet
+ * opened.
+ *
+ * @GuardedBy("sLock")
+ */
+ private static DataInputStream sUrandomIn;
+
+ /**
+ * Output stream for writing to Linux PRNG or {@code null} if not yet
+ * opened.
+ *
+ * @GuardedBy("sLock")
+ */
+ private static OutputStream sUrandomOut;
+
+ /**
+ * Whether this engine instance has been seeded. This is needed because
+ * each instance needs to seed itself if the client does not explicitly
+ * seed it.
+ */
+ private boolean mSeeded;
+
+ @Override
+ protected void engineSetSeed(byte[] bytes) {
+ try {
+ OutputStream out;
+ synchronized (sLock) {
+ out = getUrandomOutputStream();
+ }
+ out.write(bytes);
+ out.flush();
+ } catch (IOException e) {
+ // On a small fraction of devices /dev/urandom is not writable.
+ // Log and ignore.
+ Log.w(PRNGFixes.class.getSimpleName(),
+ "Failed to mix seed into " + URANDOM_FILE);
+ } finally {
+ mSeeded = true;
+ }
+ }
+
+ @Override
+ protected void engineNextBytes(byte[] bytes) {
+ if (!mSeeded) {
+ // Mix in the device- and invocation-specific seed.
+ engineSetSeed(generateSeed());
+ }
+
+ try {
+ DataInputStream in;
+ synchronized (sLock) {
+ in = getUrandomInputStream();
+ }
+ synchronized (in) {
+ in.readFully(bytes);
+ }
+ } catch (IOException e) {
+ throw new SecurityException(
+ "Failed to read from " + URANDOM_FILE, e);
+ }
+ }
+
+ @Override
+ protected byte[] engineGenerateSeed(int size) {
+ byte[] seed = new byte[size];
+ engineNextBytes(seed);
+ return seed;
+ }
+
+ private DataInputStream getUrandomInputStream() {
+ synchronized (sLock) {
+ if (sUrandomIn == null) {
+ // NOTE: Consider inserting a BufferedInputStream between
+ // DataInputStream and FileInputStream if you need higher
+ // PRNG output performance and can live with future PRNG
+ // output being pulled into this process prematurely.
+ try {
+ sUrandomIn = new DataInputStream(
+ new FileInputStream(URANDOM_FILE));
+ } catch (IOException e) {
+ throw new SecurityException("Failed to open "
+ + URANDOM_FILE + " for reading", e);
+ }
+ }
+ return sUrandomIn;
+ }
+ }
+
+ private OutputStream getUrandomOutputStream() throws IOException {
+ synchronized (sLock) {
+ if (sUrandomOut == null) {
+ sUrandomOut = new FileOutputStream(URANDOM_FILE);
+ }
+ return sUrandomOut;
+ }
+ }
+ }
+
+ /**
+ * Generates a device- and invocation-specific seed to be mixed into the
+ * Linux PRNG.
+ */
+ private static byte[] generateSeed() {
+ try {
+ ByteArrayOutputStream seedBuffer = new ByteArrayOutputStream();
+ DataOutputStream seedBufferOut =
+ new DataOutputStream(seedBuffer);
+ seedBufferOut.writeLong(System.currentTimeMillis());
+ seedBufferOut.writeLong(System.nanoTime());
+ seedBufferOut.writeInt(Process.myPid());
+ seedBufferOut.writeInt(Process.myUid());
+ seedBufferOut.write(BUILD_FINGERPRINT_AND_DEVICE_SERIAL);
+ seedBufferOut.close();
+ return seedBuffer.toByteArray();
+ } catch (IOException e) {
+ throw new SecurityException("Failed to generate seed", e);
+ }
+ }
+
+ /**
+ * Gets the hardware serial number of this device.
+ *
+ * @return serial number or {@code null} if not available.
+ */
+ private static String getDeviceSerialNumber() {
+ // We're using the Reflection API because Build.SERIAL is only available
+ // since API Level 9 (Gingerbread, Android 2.3).
+ try {
+ return (String) Build.class.getField("SERIAL").get(null);
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ private static byte[] getBuildFingerprintAndDeviceSerial() {
+ StringBuilder result = new StringBuilder();
+ String fingerprint = Build.FINGERPRINT;
+ if (fingerprint != null) {
+ result.append(fingerprint);
+ }
+ String serial = getDeviceSerialNumber();
+ if (serial != null) {
+ result.append(serial);
+ }
+ try {
+ return result.toString().getBytes("UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException("UTF-8 encoding not supported");
+ }
+ }
+}
diff --git a/mobile/android/services/src/main/res/drawable-hdpi/fxaccount_sync_error.png b/mobile/android/services/src/main/res/drawable-hdpi/fxaccount_sync_error.png
new file mode 100644
index 000000000..3a2cbc4bf
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-hdpi/fxaccount_sync_error.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_avatar_default.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_avatar_default.png
new file mode 100644
index 000000000..caa6ed246
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_avatar_default.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop.png
new file mode 100644
index 000000000..abf87f16c
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop_inactive.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop_inactive.png
new file mode 100644
index 000000000..869dbf402
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_desktop_inactive.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile.png
new file mode 100644
index 000000000..4b25152b2
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile_inactive.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile_inactive.png
new file mode 100644
index 000000000..e9401797d
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_mobile_inactive.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-hdpi/sync_promo.png b/mobile/android/services/src/main/res/drawable-hdpi/sync_promo.png
new file mode 100644
index 000000000..ea2150508
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-hdpi/sync_promo.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/fxaccount_sync_error.png b/mobile/android/services/src/main/res/drawable-xhdpi/fxaccount_sync_error.png
new file mode 100644
index 000000000..f9bf849fa
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xhdpi/fxaccount_sync_error.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop.png b/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop.png
new file mode 100644
index 000000000..30d5b5c09
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop_inactive.png b/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop_inactive.png
new file mode 100644
index 000000000..1b5b00a75
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xhdpi/sync_desktop_inactive.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile.png b/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile.png
new file mode 100644
index 000000000..2c3f45d4a
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile_inactive.png b/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile_inactive.png
new file mode 100644
index 000000000..60fd77c8a
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xhdpi/sync_mobile_inactive.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xhdpi/sync_promo.png b/mobile/android/services/src/main/res/drawable-xhdpi/sync_promo.png
new file mode 100644
index 000000000..63f1a55ad
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xhdpi/sync_promo.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/fxaccount_sync_error.png b/mobile/android/services/src/main/res/drawable-xxhdpi/fxaccount_sync_error.png
new file mode 100644
index 000000000..7555bc9d6
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xxhdpi/fxaccount_sync_error.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/sync_avatar_default.png b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_avatar_default.png
new file mode 100644
index 000000000..16d127882
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_avatar_default.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop.png b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop.png
new file mode 100644
index 000000000..9bb9a55c2
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop_inactive.png b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop_inactive.png
new file mode 100644
index 000000000..c3fe0ec1d
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_desktop_inactive.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile.png b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile.png
new file mode 100644
index 000000000..400ddf65b
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile_inactive.png b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile_inactive.png
new file mode 100644
index 000000000..a688b0d7b
--- /dev/null
+++ b/mobile/android/services/src/main/res/drawable-xxhdpi/sync_mobile_inactive.png
Binary files differ
diff --git a/mobile/android/services/src/main/res/layout/fxaccount_preference_list_fragment.xml b/mobile/android/services/src/main/res/layout/fxaccount_preference_list_fragment.xml
new file mode 100644
index 000000000..acaafc7c2
--- /dev/null
+++ b/mobile/android/services/src/main/res/layout/fxaccount_preference_list_fragment.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+** Copyright 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.
+*/
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:orientation="vertical"
+ android:layout_height="fill_parent"
+ android:layout_width="fill_parent"
+ android:background="@android:color/transparent">
+
+ <ListView android:id="@android:id/list"
+ android:layout_width="fill_parent"
+ android:layout_height="0px"
+ android:layout_weight="1"
+ android:paddingTop="0dip"
+ android:paddingBottom="@dimen/preference_fragment_padding_bottom"
+ android:paddingLeft="@dimen/preference_fragment_padding_side"
+ android:paddingRight="@dimen/preference_fragment_padding_side"
+ android:scrollbarStyle="@integer/preference_fragment_scrollbarStyle"
+ android:clipToPadding="false"
+ android:drawSelectorOnTop="false"
+ android:cacheColorHint="@android:color/transparent"
+ android:scrollbarAlwaysDrawVerticalTrack="true" />
+
+</LinearLayout>
diff --git a/mobile/android/services/src/main/res/layout/fxaccount_status_error_preference.xml b/mobile/android/services/src/main/res/layout/fxaccount_status_error_preference.xml
new file mode 100644
index 000000000..4a507cddd
--- /dev/null
+++ b/mobile/android/services/src/main/res/layout/fxaccount_status_error_preference.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="fill_parent"
+ android:layout_height="wrap_content"
+ android:background="@color/fxaccount_error_preference_backgroundcolor"
+ android:gravity="center_vertical"
+ android:minHeight="?android:attr/listPreferredItemHeight"
+ android:paddingRight="?android:attr/scrollbarSize" >
+
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:gravity="center"
+ android:minWidth="0dp"
+ android:orientation="horizontal" >
+
+ <ImageView
+ android:id="@+android:id/icon"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:minWidth="48dip"
+ android:padding="10dip" />
+ </LinearLayout>
+
+ <RelativeLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="6dip"
+ android:layout_marginLeft="15dip"
+ android:layout_marginRight="6dip"
+ android:layout_marginTop="6dip"
+ android:layout_weight="1" >
+
+ <TextView
+ android:id="@+android:id/title"
+ style="@style/FxAccountTextItem"
+ android:layout_width="fill_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:gravity="center_vertical" >
+ </TextView>
+ </RelativeLayout>
+
+ <!-- We ignore summary and widget_frame, but they still need to be present. We set them to be gone. -->
+
+ <TextView
+ android:id="@+android:id/summary"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:maxLines="4"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="?android:attr/textColorSecondary"
+ android:visibility="gone" />
+
+ <!-- Preference should place its actual preference widget here. -->
+
+ <LinearLayout
+ android:id="@+android:id/widget_frame"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:gravity="center"
+ android:orientation="vertical"
+ android:visibility="gone" />
+
+</LinearLayout>
diff --git a/mobile/android/services/src/main/res/layout/homescreen_prompt.xml b/mobile/android/services/src/main/res/layout/homescreen_prompt.xml
new file mode 100644
index 000000000..26d04ad17
--- /dev/null
+++ b/mobile/android/services/src/main/res/layout/homescreen_prompt.xml
@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<merge xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipChildren="false"
+ android:clipToPadding="false">
+
+ <RelativeLayout
+ android:id="@+id/container"
+ android:layout_width="@dimen/overlay_prompt_container_width"
+ android:layout_height="wrap_content"
+ android:layout_gravity="bottom|center"
+ android:background="@android:color/white"
+ android:clickable="true"
+ android:orientation="vertical">
+
+ <ImageView
+ android:id="@+id/close"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_alignParentRight="true"
+ android:layout_marginLeft="10dp"
+ android:layout_marginRight="30dp"
+ android:layout_marginTop="30dp"
+ android:ellipsize="end"
+ android:maxLines="2"
+ android:padding="6dp"
+ android:src="@drawable/tab_close_active" />
+
+ <TextView
+ android:id="@+id/title"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="6dp"
+ android:layout_marginLeft="30dp"
+ android:layout_marginTop="30dp"
+ android:layout_toLeftOf="@id/close"
+ android:fontFamily="sans-serif-light"
+ android:textColor="@color/text_and_tabs_tray_grey"
+ android:textSize="20sp"
+ tools:text="The Pokedex" />
+
+ <TextView
+ android:id="@+id/host"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@id/title"
+ android:layout_marginBottom="20dp"
+ android:layout_marginLeft="30dp"
+ android:layout_marginRight="30dp"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:textColor="@color/placeholder_grey"
+ android:textSize="16sp"
+ tools:text="pokedex.org" />
+
+ <ImageView
+ android:id="@+id/icon"
+ android:layout_width="50dp"
+ android:layout_height="50dp"
+ android:layout_below="@id/host"
+ android:layout_marginBottom="20dp"
+ android:layout_marginLeft="30dp"
+ android:src="@drawable/icon" />
+
+ <Button
+ android:id="@+id/add"
+ style="@style/Widget.BaseButton"
+ android:layout_width="wrap_content"
+ android:layout_height="50dp"
+ android:layout_alignParentRight="true"
+ android:layout_below="@id/host"
+ android:layout_marginBottom="20dp"
+ android:layout_marginLeft="100dp"
+ android:layout_marginRight="30dp"
+ android:background="@drawable/button_background_action_orange_round"
+ android:paddingLeft="16dp"
+ android:paddingRight="16dp"
+ android:text="@string/promotion_add_to_homescreen"
+ android:maxLines="2"
+ android:ellipsize="end"
+ android:textColor="@android:color/white"
+ android:textSize="16sp" />
+
+ </RelativeLayout>
+</merge>
diff --git a/mobile/android/services/src/main/res/layout/simple_helper_ui.xml b/mobile/android/services/src/main/res/layout/simple_helper_ui.xml
new file mode 100644
index 000000000..f549d5c31
--- /dev/null
+++ b/mobile/android/services/src/main/res/layout/simple_helper_ui.xml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<merge xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipChildren="false"
+ android:clipToPadding="false">
+
+ <LinearLayout
+ android:id="@+id/container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="@android:color/white"
+ android:layout_gravity="bottom|center"
+ android:clickable="true"
+ android:orientation="vertical">
+
+ <ImageView
+ android:id="@+id/image"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="40dp"
+ android:layout_marginBottom="40dp"
+ android:scaleType="fitCenter"
+ android:layout_gravity="center"
+ android:adjustViewBounds="true"/>
+
+ <TextView
+ android:id="@+id/title"
+ android:layout_width="@dimen/firstrun_content_width"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:gravity="center"
+ android:textAppearance="@style/TextAppearance.FirstrunLight.Main"/>
+
+
+ <TextView
+ android:id="@+id/message"
+ android:layout_width="@dimen/firstrun_content_width"
+ android:layout_height="wrap_content"
+ android:paddingTop="20dp"
+ android:paddingBottom="30dp"
+ android:layout_gravity="center"
+ android:gravity="center"
+ android:textAppearance="@style/TextAppearance.FirstrunRegular.Body"
+ android:singleLine="false"/>
+
+ <Button
+ android:id="@+id/button"
+ style="@style/Widget.Firstrun.Button"
+ android:background="@drawable/button_background_action_orange_round"
+ android:layout_gravity="center"
+ android:layout_marginBottom="30dp"/>
+
+ </LinearLayout>
+</merge>
diff --git a/mobile/android/services/src/main/res/menu/fxaccount_status_menu.xml b/mobile/android/services/src/main/res/menu/fxaccount_status_menu.xml
new file mode 100644
index 000000000..16f72a7ca
--- /dev/null
+++ b/mobile/android/services/src/main/res/menu/fxaccount_status_menu.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android" >
+ <item
+ android:id="@+id/enable_debug_mode"
+ android:checkable="true"
+ android:checked="false"
+ android:title="@string/fxaccount_enable_debug_mode" />
+</menu>
diff --git a/mobile/android/services/src/main/res/values-v11/fxaccount_styles.xml b/mobile/android/services/src/main/res/values-v11/fxaccount_styles.xml
new file mode 100644
index 000000000..5c0a23db5
--- /dev/null
+++ b/mobile/android/services/src/main/res/values-v11/fxaccount_styles.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/.
+-->
+
+<resources xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <!-- FxAccountStatusActivity ActionBar -->
+ <style name="ActionBar.FxAccountStatusActivity">
+ <item name="android:displayOptions">showHome|homeAsUp|showTitle</item>
+ </style>
+
+ <style name="FxAccountTheme" parent="Gecko.Preferences" />
+
+ <style name="FxAccountTheme.FxAccountStatusActivity" parent="Gecko.Preferences">
+ <item name="android:actionBarStyle">@style/ActionBar.FxAccountStatusActivity</item>
+ </style>
+
+</resources>
diff --git a/mobile/android/services/src/main/res/values/fxaccount_colors.xml b/mobile/android/services/src/main/res/values/fxaccount_colors.xml
new file mode 100644
index 000000000..f7140faff
--- /dev/null
+++ b/mobile/android/services/src/main/res/values/fxaccount_colors.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<resources xmlns:android="http://schemas.android.com/apk/res/android">
+ <color name="fxaccount_textColor">#424f59</color>
+ <color name="fxaccount_error_preference_backgroundcolor">#fad4d2</color>
+</resources>
diff --git a/mobile/android/services/src/main/res/values/fxaccount_dimens.xml b/mobile/android/services/src/main/res/values/fxaccount_dimens.xml
new file mode 100644
index 000000000..d1d44585d
--- /dev/null
+++ b/mobile/android/services/src/main/res/values/fxaccount_dimens.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<resources>
+ <!-- Preference fragment padding, bottom -->
+ <dimen name="preference_fragment_padding_bottom">0dp</dimen>
+ <!-- Preference fragment padding, sides -->
+ <dimen name="preference_fragment_padding_side">16dp</dimen>
+
+ <integer name="preference_fragment_scrollbarStyle">0x02000000</integer> <!-- outsideOverlay -->
+
+ <!-- Profile avatar image height. -->
+ <dimen name="fxaccount_profile_image_height">48dp</dimen>
+ <!-- Profile avatar image width. -->
+ <dimen name="fxaccount_profile_image_width">48dp</dimen>
+</resources>
diff --git a/mobile/android/services/src/main/res/values/fxaccount_styles.xml b/mobile/android/services/src/main/res/values/fxaccount_styles.xml
new file mode 100644
index 000000000..d74efac91
--- /dev/null
+++ b/mobile/android/services/src/main/res/values/fxaccount_styles.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/.
+-->
+
+<resources xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <style name="FxAccountTheme" parent="Gecko.Preferences" />
+
+ <style name="FxAccountTheme.FxAccountStatusActivity" parent="@style/FxAccountTheme">
+ <item name="android:windowNoTitle">false</item>
+ </style>
+
+ <style name="FxAccountTextItem" parent="@android:style/TextAppearance.Medium">
+ <item name="android:textColor">@color/fxaccount_textColor</item>
+ <item name="android:layout_width">fill_parent</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:gravity">center_horizontal</item>
+ <item name="android:textSize">14sp</item>
+ <item name="android:layout_marginBottom">10dp</item>
+ <item name="android:layout_marginLeft">10dp</item>
+ <item name="android:layout_marginRight">10dp</item>
+ </style>
+
+</resources>
diff --git a/mobile/android/services/src/main/res/xml/fxaccount_authenticator.xml b/mobile/android/services/src/main/res/xml/fxaccount_authenticator.xml
new file mode 100644
index 000000000..7b004e209
--- /dev/null
+++ b/mobile/android/services/src/main/res/xml/fxaccount_authenticator.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
+ android:accountType="@string/moz_android_shared_fxaccount_type"
+ android:icon="@drawable/icon"
+ android:smallIcon="@drawable/icon"
+ android:label="@string/fxaccount_label"
+ android:accountPreferences="@xml/fxaccount_options" />
diff --git a/mobile/android/services/src/main/res/xml/fxaccount_options.xml b/mobile/android/services/src/main/res/xml/fxaccount_options.xml
new file mode 100644
index 000000000..449fc0545
--- /dev/null
+++ b/mobile/android/services/src/main/res/xml/fxaccount_options.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
+ <PreferenceCategory
+ android:title="@string/fxaccount_options_title" />
+ <PreferenceScreen
+ android:key="options"
+ android:title="@string/fxaccount_options_configure_title">
+ <intent
+ android:action="android.intent.action.MAIN"
+ android:targetPackage="@string/android_package_name_for_ui"
+ android:targetClass="org.mozilla.gecko.fxa.activities.FxAccountStatusActivity">
+ </intent>
+ </PreferenceScreen>
+</PreferenceScreen>
diff --git a/mobile/android/services/src/main/res/xml/fxaccount_status_prefscreen.xml b/mobile/android/services/src/main/res/xml/fxaccount_status_prefscreen.xml
new file mode 100644
index 000000000..570e362cc
--- /dev/null
+++ b/mobile/android/services/src/main/res/xml/fxaccount_status_prefscreen.xml
@@ -0,0 +1,142 @@
+<?xml version="1.0" encoding="utf-8"?>
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:gecko="http://schemas.android.com/apk/res-auto"
+ android:key="status_screen">
+
+ <PreferenceCategory
+ android:key="signed_in_as_category"
+ android:title="@string/fxaccount_status_signed_in_as" >
+ <Preference
+ android:editable="false"
+ android:key="profile"
+ android:icon="@drawable/sync_avatar_default"
+ android:persistent="false"
+ android:title="" />
+ <Preference
+ android:editable="false"
+ android:key="manage_account"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_manage_account" />
+ <Preference
+ android:editable="false"
+ android:key="auth_server"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_auth_server" />
+ </PreferenceCategory>
+ <PreferenceCategory
+ android:key="sync_category"
+ android:title="@string/fxaccount_status_sync" >
+ <Preference
+ android:editable="false"
+ android:icon="@drawable/fxaccount_sync_error"
+ android:key="needs_credentials"
+ android:layout="@layout/fxaccount_status_error_preference"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_needs_credentials" />
+ <Preference
+ android:editable="false"
+ android:icon="@drawable/fxaccount_sync_error"
+ android:key="needs_upgrade"
+ android:layout="@layout/fxaccount_status_error_preference"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_needs_upgrade" />
+ <Preference
+ android:editable="false"
+ android:icon="@drawable/fxaccount_sync_error"
+ android:key="needs_verification"
+ android:layout="@layout/fxaccount_status_error_preference"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_needs_verification" />
+ <Preference
+ android:editable="false"
+ android:icon="@drawable/fxaccount_sync_error"
+ android:key="needs_master_sync_automatically_enabled"
+ android:layout="@layout/fxaccount_status_error_preference"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_needs_master_sync_automatically_enabled" />
+ <Preference
+ android:editable="false"
+ android:icon="@drawable/fxaccount_sync_error"
+ android:key="needs_finish_migrating"
+ android:layout="@layout/fxaccount_status_error_preference"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_needs_finish_migrating" />
+
+ <Preference
+ android:editable="false"
+ android:key="sync_now"
+ android:defaultValue=""
+ android:persistent="false"
+ android:title="@string/fxaccount_status_sync_now"
+ android:summary="" />
+
+ <CheckBoxPreference
+ android:key="bookmarks"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_bookmarks" />
+ <CheckBoxPreference
+ android:key="history"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_history" />
+ <CheckBoxPreference
+ android:key="tabs"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_tabs" />
+ <CheckBoxPreference
+ android:key="passwords"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_passwords" />
+
+ <EditTextPreference
+ android:singleLine="true"
+ android:key="device_name"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_device_name" />
+
+ <Preference
+ android:editable="false"
+ android:key="sync_server"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_sync_server" />
+ <org.mozilla.gecko.fxa.activities.CustomColorPreference
+ android:editable="false"
+ android:key="remove_account"
+ android:persistent="false"
+ gecko:titleColor="@color/rejection_red"
+ android:title="@string/fxaccount_remove_account" />
+ <Preference
+ android:editable="false"
+ android:key="more"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_more" />
+
+ </PreferenceCategory>
+ <PreferenceCategory
+ android:key="legal_category"
+ android:title="@string/fxaccount_status_legal" >
+ <Preference
+ android:editable="false"
+ android:key="linktos"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_linktos" />
+ <Preference
+ android:editable="false"
+ android:key="linkprivacy"
+ android:persistent="false"
+ android:title="@string/fxaccount_status_linkprivacy" />
+ </PreferenceCategory>
+ <PreferenceCategory
+ android:key="debug_category" >
+ <Preference android:key="debug_refresh" />
+ <Preference android:key="debug_dump" />
+ <Preference android:key="debug_force_sync" />
+ <Preference android:key="debug_invalidate_certificate" />
+ <Preference android:key="debug_forget_certificate" />
+ <Preference android:key="debug_require_password" />
+ <Preference android:key="debug_require_upgrade" />
+ <Preference android:key="debug_migrated_from_sync11" />
+ <Preference android:key="debug_make_account_stage" />
+ <Preference android:key="debug_make_account_default" />
+ </PreferenceCategory>
+
+</PreferenceScreen>
diff --git a/mobile/android/services/src/main/res/xml/fxaccount_syncadapter.xml b/mobile/android/services/src/main/res/xml/fxaccount_syncadapter.xml
new file mode 100644
index 000000000..761920667
--- /dev/null
+++ b/mobile/android/services/src/main/res/xml/fxaccount_syncadapter.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
+ android:accountType="@string/moz_android_shared_fxaccount_type"
+ android:contentAuthority="@string/content_authority_db_browser"
+ android:isAlwaysSyncable="true"
+ android:supportsUploading="true"
+ android:userVisible="true"
+/>