summaryrefslogtreecommitdiffstats
path: root/mobile/android/tests/background/junit4/src
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /mobile/android/tests/background/junit4/src
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'mobile/android/tests/background/junit4/src')
-rw-r--r--mobile/android/tests/background/junit4/src/com/keepsafe/switchboard/TestSwitchboard.java142
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBackoff.java114
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBrowserIDAuthHeaderProvider.java23
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java806
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestCredentialsEndToEnd.java72
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestGlobalSession.java436
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestHeaderParsing.java29
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestLineByLineHandling.java115
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestMetaGlobal.java347
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestResource.java102
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestRetryAfter.java87
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestServer11Repository.java48
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestSyncStorageRequest.java269
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/SynchronizerHelpers.java282
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCollectionKeys.java197
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCommandProcessor.java117
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCryptoRecord.java302
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecord.java330
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecordsChannel.java229
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestResetCommands.java153
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServer11RepositorySession.java231
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServerLocalSynchronizer.java237
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSyncConfiguration.java39
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizer.java398
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizerSession.java306
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestUtils.java154
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/BaseTestStorageRequestDelegate.java61
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessDelegate.java35
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionBeginDelegate.java38
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionCreationDelegate.java36
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFetchRecordsDelegate.java44
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFinishDelegate.java37
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionStoreDelegate.java39
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositoryWipeDelegate.java36
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/HTTPServerTestHelper.java226
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockGlobalSessionCallback.java91
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockResourceDelegate.java85
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockServer.java73
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockSyncClientsEngineStage.java71
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockWBOServer.java28
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/test/TestHTTPServerTestHelper.java102
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/GeckoNetworkManagerTest.java51
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/GlobalPageMetadataTest.java174
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/TestGeckoProfile.java254
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/activitystream/TestActivityStream.java85
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/common/log/writers/test/TestLogWriters.java179
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/DelegatingTestContentProvider.java86
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/TestTabsProvider.java338
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/TestTabsProviderRemoteTabs.java244
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountClient20.java41
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountUtils.java131
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/test/EntityTestHelper.java34
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/BaseMockServerSyncStage.java72
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/CommandHelpers.java40
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/DefaultGlobalSessionCallback.java51
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockAbstractNonRepositorySyncStage.java13
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockClientsDataDelegate.java66
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockClientsDatabaseAccessor.java76
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockGlobalSession.java57
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockPrefsGlobalSession.java60
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockRecord.java51
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockServerSyncStage.java11
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockSharedPreferences.java137
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/TestRunner.java125
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WBORepository.java230
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WaitHelper.java172
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestASNUtils.java45
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestDSACryptoImplementation.java60
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestJSONWebTokenUtils.java151
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestRSACryptoImplementation.java56
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/cleanup/TestFileCleanupController.java92
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/cleanup/TestFileCleanupService.java106
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserContractTest.java67
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHighlightsTest.java438
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryTest.java341
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTest.java338
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTestBase.java77
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderVisitsTest.java301
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/distribution/TestReferrerDescriptor.java33
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestDownloadAction.java607
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestStudyAction.java119
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestSyncAction.java276
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestVerifyAction.java123
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentBuilder.java69
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentCatalog.java262
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteBlogger.java74
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteMedium.java66
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteTumblr.java62
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/parser/TestSimpleFeedParser.java323
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/TestSkewHandler.java70
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java226
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestFxAccountLoginStateMachine.java205
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestStateFactory.java91
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/helpers/AssertUtil.java29
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/home/TestHomeConfigPrefsBackendMigration.java264
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptor.java56
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptorComparator.java152
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequest.java81
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequestBuilder.java159
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconResponse.java148
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconTask.java575
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconsHelper.java139
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestContentProviderLoader.java31
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDataUriLoader.java46
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDiskLoader.java94
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconDownloader.java112
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconGenerator.java128
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestJarLoader.java31
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestLegacyLoader.java152
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestMemoryLoader.java78
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAboutPagesPreparer.java73
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAddDefaultIconUrl.java79
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterKnownFailureUrls.java60
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterMimeTypes.java67
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterPrivilegedUrls.java86
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestLookupIconUrl.java101
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestColorProcessor.java59
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestDiskProcessor.java100
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestMemoryProcessor.java134
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestResizingProcessor.java111
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/permissions/TestPermissions.java253
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushManager.java238
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushState.java70
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestAutopushClient.java30
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestLiveAutopushClient.java171
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestBase32.java53
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestCryptoInfo.java144
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestHKDF.java143
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestKeyBundle.java65
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestPBKDF2.java124
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestPersistedCrypto5Keys.java83
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestSRPConstants.java45
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/middleware/test/TestCrypto5MiddlewareRepositorySession.java291
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestHMACAuthHeaderProvider.java165
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestHawkAuthHeaderProvider.java145
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestLiveHawkAuth.java181
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestUserAgentHeaders.java131
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpersTest.java33
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/VisitsHelperTest.java144
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/test/TestBookmarksInsertionManager.java221
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/domain/TestClientRecord.java103
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/domain/test/TestFormHistoryRecord.java92
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegateTest.java186
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderTest.java543
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestRepositorySessionBundle.java47
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestSafeConstrainedServer11Repository.java144
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/BatchMetaTest.java282
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploaderTest.java441
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/PayloadTest.java137
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/PayloadUploadDelegateTest.java404
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/RecordUploadRunnableTest.java38
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestEnsureCrypto5KeysStage.java237
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestFetchMetaGlobalStage.java391
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestStageLookup.java41
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestExtendedJSONObject.java203
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestInfoCollections.java101
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestPersistedMetaGlobal.java105
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSearchCountMeasurements.java161
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSessionMeasurements.java124
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/pingbuilders/TestTelemetryPingBuilder.java84
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/schedulers/TestTelemetryUploadAllPingsImmediatelyScheduler.java59
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/stores/TestTelemetryJSONFilePingStore.java250
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/tokenserver/test/TestTokenServerClient.java335
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/NetworkUtilsTest.java185
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestContextUtils.java38
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestDateUtil.java89
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestFileUtils.java339
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestIntentUtils.java73
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestStringUtils.java122
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestUUIDUtil.java51
-rw-r--r--mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/publicsuffix/TestPublicSuffix.java62
171 files changed, 25365 insertions, 0 deletions
diff --git a/mobile/android/tests/background/junit4/src/com/keepsafe/switchboard/TestSwitchboard.java b/mobile/android/tests/background/junit4/src/com/keepsafe/switchboard/TestSwitchboard.java
new file mode 100644
index 000000000..5726f12db
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/com/keepsafe/switchboard/TestSwitchboard.java
@@ -0,0 +1,142 @@
+package com.keepsafe.switchboard;
+
+import android.content.Context;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.Experiments;
+import org.mozilla.gecko.util.IOUtils;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class TestSwitchboard {
+
+ /**
+ * Create a JSON response from a JSON file.
+ */
+ private String readFromFile(String fileName) throws IOException {
+ URL url = getClass().getResource("/" + fileName);
+ if (url == null) {
+ throw new FileNotFoundException(fileName);
+ }
+
+ InputStream inputStream = null;
+ ByteArrayOutputStream outputStream = null;
+
+ try {
+ inputStream = new BufferedInputStream(new FileInputStream(url.getPath()));
+ InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
+ BufferedReader bufferReader = new BufferedReader(inputStreamReader, 8192);
+ String line;
+ StringBuilder resultContent = new StringBuilder();
+ while ((line = bufferReader.readLine()) != null) {
+ resultContent.append(line);
+ }
+ bufferReader.close();
+
+ return resultContent.toString();
+
+ } finally {
+ IOUtils.safeStreamClose(inputStream);
+ }
+ }
+
+ @Before
+ public void setUp() throws IOException {
+ final Context c = RuntimeEnvironment.application;
+ Preferences.setDynamicConfigJson(c, readFromFile("experiments.json"));
+ }
+
+ @Test
+ public void testDeviceUuidFactory() {
+ final Context c = RuntimeEnvironment.application;
+ final DeviceUuidFactory df = new DeviceUuidFactory(c);
+ final UUID uuid = df.getDeviceUuid();
+ assertNotNull("UUID is not null", uuid);
+ assertEquals("DeviceUuidFactory always returns the same UUID", df.getDeviceUuid(), uuid);
+ }
+
+ @Test
+ public void testIsInExperiment() {
+ final Context c = RuntimeEnvironment.application;
+ assertTrue("active-experiment is active", SwitchBoard.isInExperiment(c, "active-experiment"));
+ assertFalse("inactive-experiment is inactive", SwitchBoard.isInExperiment(c, "inactive-experiment"));
+ }
+
+ @Test
+ public void testExperimentValues() throws JSONException {
+ final Context c = RuntimeEnvironment.application;
+ assertTrue("active-experiment has values", SwitchBoard.hasExperimentValues(c, "active-experiment"));
+ assertFalse("inactive-experiment doesn't have values", SwitchBoard.hasExperimentValues(c, "inactive-experiment"));
+
+ final JSONObject values = SwitchBoard.getExperimentValuesFromJson(c, "active-experiment");
+ assertNotNull("active-experiment values are not null", values);
+ assertTrue("\"foo\" extra value is true", values.getBoolean("foo"));
+ }
+
+ @Test
+ public void testGetActiveExperiments() {
+ final Context c = RuntimeEnvironment.application;
+ final List<String> experiments = SwitchBoard.getActiveExperiments(c);
+ assertNotNull("List of active experiments is not null", experiments);
+
+ assertTrue("List of active experiments contains active-experiment", experiments.contains("active-experiment"));
+ assertFalse("List of active experiments does not contain inactive-experiment", experiments.contains("inactive-experiment"));
+ }
+
+ @Test
+ public void testOverride() {
+ final Context c = RuntimeEnvironment.application;
+
+ Experiments.setOverride(c, "active-experiment", false);
+ assertFalse("active-experiment is not active because of override", SwitchBoard.isInExperiment(c, "active-experiment"));
+ assertFalse("List of active experiments does not contain active-experiment", SwitchBoard.getActiveExperiments(c).contains("active-experiment"));
+
+ Experiments.clearOverride(c, "active-experiment");
+ assertTrue("active-experiment is active after override is cleared", SwitchBoard.isInExperiment(c, "active-experiment"));
+ assertTrue("List of active experiments contains active-experiment again", SwitchBoard.getActiveExperiments(c).contains("active-experiment"));
+
+ Experiments.setOverride(c, "inactive-experiment", true);
+ assertTrue("inactive-experiment is active because of override", SwitchBoard.isInExperiment(c, "inactive-experiment"));
+ assertTrue("List of active experiments contains inactive-experiment", SwitchBoard.getActiveExperiments(c).contains("inactive-experiment"));
+
+ Experiments.clearOverride(c, "inactive-experiment");
+ assertFalse("inactive-experiment is inactive after override is cleared", SwitchBoard.isInExperiment(c, "inactive-experiment"));
+ assertFalse("List of active experiments does not contain inactive-experiment again", SwitchBoard.getActiveExperiments(c).contains("inactive-experiment"));
+ }
+
+ @Test
+ public void testMatching() {
+ final Context c = RuntimeEnvironment.application;
+ assertTrue("is-experiment is matching", SwitchBoard.isInExperiment(c, "is-matching"));
+ assertFalse("is-not-matching is not matching", SwitchBoard.isInExperiment(c, "is-not-matching"));
+ }
+
+ @Test
+ public void testNotExisting() {
+ final Context c = RuntimeEnvironment.application;
+ assertFalse("F0O does not exists", SwitchBoard.isInExperiment(c, "F0O"));
+ assertFalse("BaAaz does not exists", SwitchBoard.hasExperimentValues(c, "BaAaz"));
+ }
+
+
+
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBackoff.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBackoff.java
new file mode 100644
index 000000000..8e8152e36
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBackoff.java
@@ -0,0 +1,114 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+import ch.boye.httpclientandroidlib.message.BasicStatusLine;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.MockGlobalSessionCallback;
+import org.mozilla.gecko.background.testhelpers.MockGlobalSession;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+@RunWith(TestRunner.class)
+public class TestBackoff {
+ private final String TEST_USERNAME = "johndoe";
+ private final String TEST_PASSWORD = "password";
+ private final String TEST_SYNC_KEY = "abcdeabcdeabcdeabcdeabcdea";
+ private final long TEST_BACKOFF_IN_SECONDS = 1201;
+
+ /**
+ * Test that interpretHTTPFailure calls requestBackoff if
+ * X-Weave-Backoff is present.
+ */
+ @Test
+ public void testBackoffCalledIfBackoffHeaderPresent() {
+ try {
+ final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+ SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+ final GlobalSession session = new MockGlobalSession(config, callback);
+
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
+ response.addHeader("X-Weave-Backoff", Long.toString(TEST_BACKOFF_IN_SECONDS)); // Backoff given in seconds.
+
+ session.interpretHTTPFailure(response); // This is synchronous...
+
+ assertEquals(false, callback.calledSuccess); // ... so we can test immediately.
+ assertEquals(false, callback.calledError);
+ assertEquals(false, callback.calledAborted);
+ assertEquals(true, callback.calledRequestBackoff);
+ assertEquals(TEST_BACKOFF_IN_SECONDS * 1000, callback.weaveBackoff); // Backoff returned in milliseconds.
+ } catch (Exception e) {
+ e.printStackTrace();
+ fail("Got exception.");
+ }
+ }
+
+ /**
+ * Test that interpretHTTPFailure does not call requestBackoff if
+ * X-Weave-Backoff is not present.
+ */
+ @Test
+ public void testBackoffNotCalledIfBackoffHeaderNotPresent() {
+ try {
+ final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+ SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+ final GlobalSession session = new MockGlobalSession(config, callback);
+
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
+
+ session.interpretHTTPFailure(response); // This is synchronous...
+
+ assertEquals(false, callback.calledSuccess); // ... so we can test immediately.
+ assertEquals(false, callback.calledError);
+ assertEquals(false, callback.calledAborted);
+ assertEquals(false, callback.calledRequestBackoff);
+ } catch (Exception e) {
+ e.printStackTrace();
+ fail("Got exception.");
+ }
+ }
+
+ /**
+ * Test that interpretHTTPFailure calls requestBackoff with the
+ * largest specified value if X-Weave-Backoff and Retry-After are
+ * present.
+ */
+ @Test
+ public void testBackoffCalledIfMultipleBackoffHeadersPresent() {
+ try {
+ final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+ SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+ final GlobalSession session = new MockGlobalSession(config, callback);
+
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
+ response.addHeader("Retry-After", Long.toString(TEST_BACKOFF_IN_SECONDS)); // Backoff given in seconds.
+ response.addHeader("X-Weave-Backoff", Long.toString(TEST_BACKOFF_IN_SECONDS + 1)); // If we now add a second header, the larger should be returned.
+
+ session.interpretHTTPFailure(response); // This is synchronous...
+
+ assertEquals(false, callback.calledSuccess); // ... so we can test immediately.
+ assertEquals(false, callback.calledError);
+ assertEquals(false, callback.calledAborted);
+ assertEquals(true, callback.calledRequestBackoff);
+ assertEquals((TEST_BACKOFF_IN_SECONDS + 1) * 1000, callback.weaveBackoff); // Backoff returned in milliseconds.
+ } catch (Exception e) {
+ e.printStackTrace();
+ fail("Got exception.");
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBrowserIDAuthHeaderProvider.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBrowserIDAuthHeaderProvider.java
new file mode 100644
index 000000000..6a14c6d29
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBrowserIDAuthHeaderProvider.java
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import ch.boye.httpclientandroidlib.Header;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.net.BrowserIDAuthHeaderProvider;
+
+import static org.junit.Assert.assertEquals;
+
+@RunWith(TestRunner.class)
+public class TestBrowserIDAuthHeaderProvider {
+ @Test
+ public void testHeader() {
+ Header header = new BrowserIDAuthHeaderProvider("assertion").getAuthHeader(null, null, null);
+
+ assertEquals("authorization", header.getName().toLowerCase());
+ assertEquals("BrowserID assertion", header.getValue());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java
new file mode 100644
index 000000000..920cafb35
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java
@@ -0,0 +1,806 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import ch.boye.httpclientandroidlib.HttpStatus;
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockGlobalSessionCallback;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.android.sync.test.helpers.MockSyncClientsEngineStage;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.CommandHelpers;
+import org.mozilla.gecko.background.testhelpers.MockClientsDataDelegate;
+import org.mozilla.gecko.background.testhelpers.MockClientsDatabaseAccessor;
+import org.mozilla.gecko.background.testhelpers.MockGlobalSession;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.CollectionKeys;
+import org.mozilla.gecko.sync.CommandProcessor.Command;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.SyncConfigurationException;
+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.delegates.GlobalSessionCallback;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.math.BigDecimal;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Some tests in this class run client/server multi-threaded code but JUnit assertions triggered
+ * from background threads do not fail the test. If you see unexplained connection-related test failures,
+ * an assertion on the server may have been thrown. Unfortunately, it is non-trivial to get the background
+ * threads to transfer failures back to the test thread so we leave the tests in this state for now.
+ *
+ * One reason the server might throw an assertion is if you have not installed the crypto policies. See
+ * https://wiki.mozilla.org/Mobile/Fennec/Android/Testing#JUnit4_tests for more information.
+ */
+@RunWith(TestRunner.class)
+public class TestClientsEngineStage extends MockSyncClientsEngineStage {
+ public final static String LOG_TAG = "TestClientsEngSta";
+
+ public TestClientsEngineStage() throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, CryptoException, URISyntaxException {
+ super();
+ session = initializeSession();
+ }
+
+ // Static so we can set it during the constructor. This is so evil.
+ private static MockGlobalSessionCallback callback;
+ private static GlobalSession initializeSession() throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, CryptoException, URISyntaxException {
+ callback = new MockGlobalSessionCallback();
+ SyncConfiguration config = new SyncConfiguration(USERNAME, new BasicAuthHeaderProvider(USERNAME, PASSWORD), new MockSharedPreferences());
+ config.syncKeyBundle = new KeyBundle(USERNAME, SYNC_KEY);
+ GlobalSession session = new MockClientsGlobalSession(config, callback);
+ session.config.setClusterURL(new URI(TEST_SERVER));
+ session.config.setCollectionKeys(CollectionKeys.generateCollectionKeys());
+ return session;
+ }
+
+ private static final int TEST_PORT = HTTPServerTestHelper.getTestPort();
+ private static final String TEST_SERVER = "http://localhost:" + TEST_PORT;
+
+ private static final String USERNAME = "john";
+ private static final String PASSWORD = "password";
+ private static final String SYNC_KEY = "abcdeabcdeabcdeabcdeabcdea";
+
+ private HTTPServerTestHelper data = new HTTPServerTestHelper();
+ private int numRecordsFromGetRequest = 0;
+
+ private ArrayList<ClientRecord> expectedClients = new ArrayList<ClientRecord>();
+ private ArrayList<ClientRecord> downloadedClients = new ArrayList<ClientRecord>();
+
+ // For test purposes.
+ private ClientRecord lastComputedLocalClientRecord;
+ private ClientRecord uploadedRecord;
+ private String uploadBodyTimestamp;
+ private long uploadHeaderTimestamp;
+ private MockServer currentUploadMockServer;
+ private MockServer currentDownloadMockServer;
+
+ private boolean stubUpload = false;
+
+ protected static WaitHelper testWaiter() {
+ return WaitHelper.getTestWaiter();
+ }
+
+ @Override
+ protected ClientRecord newLocalClientRecord(ClientsDataDelegate delegate) {
+ lastComputedLocalClientRecord = super.newLocalClientRecord(delegate);
+ return lastComputedLocalClientRecord;
+ }
+
+ @After
+ public void teardown() {
+ stubUpload = false;
+ getMockDataAccessor().resetVars();
+ }
+
+ @Override
+ public synchronized ClientsDatabaseAccessor getClientsDatabaseAccessor() {
+ if (db == null) {
+ db = new MockClientsDatabaseAccessor();
+ }
+ return db;
+ }
+
+ // For test use.
+ private MockClientsDatabaseAccessor getMockDataAccessor() {
+ return (MockClientsDatabaseAccessor) getClientsDatabaseAccessor();
+ }
+
+ private synchronized boolean mockDataAccessorIsClosed() {
+ if (db == null) {
+ return true;
+ }
+ return ((MockClientsDatabaseAccessor) db).closed;
+ }
+
+ @Override
+ protected ClientDownloadDelegate makeClientDownloadDelegate() {
+ return clientDownloadDelegate;
+ }
+
+ @Override
+ protected void downloadClientRecords() {
+ BaseResource.rewriteLocalhost = false;
+ data.startHTTPServer(currentDownloadMockServer);
+ super.downloadClientRecords();
+ }
+
+ @Override
+ protected void uploadClientRecord(CryptoRecord record) {
+ BaseResource.rewriteLocalhost = false;
+ if (stubUpload) {
+ session.advance();
+ return;
+ }
+ data.startHTTPServer(currentUploadMockServer);
+ super.uploadClientRecord(record);
+ }
+
+ @Override
+ protected void uploadClientRecords(JSONArray records) {
+ BaseResource.rewriteLocalhost = false;
+ if (stubUpload) {
+ return;
+ }
+ data.startHTTPServer(currentUploadMockServer);
+ super.uploadClientRecords(records);
+ }
+
+ public static class MockClientsGlobalSession extends MockGlobalSession {
+ private ClientsDataDelegate clientsDataDelegate = new MockClientsDataDelegate();
+
+ public MockClientsGlobalSession(SyncConfiguration config,
+ GlobalSessionCallback callback)
+ throws SyncConfigurationException,
+ IllegalArgumentException,
+ IOException,
+ NonObjectJSONException {
+ super(config, callback);
+ }
+
+ @Override
+ public ClientsDataDelegate getClientsDelegate() {
+ return clientsDataDelegate;
+ }
+ }
+
+ public class TestSuccessClientDownloadDelegate extends TestClientDownloadDelegate {
+ public TestSuccessClientDownloadDelegate(HTTPServerTestHelper data) {
+ super(data);
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ super.handleRequestFailure(response);
+ assertTrue(getMockDataAccessor().closed);
+ fail("Should not error.");
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ super.handleRequestError(ex);
+ assertTrue(getMockDataAccessor().closed);
+ fail("Should not fail.");
+ }
+
+ @Override
+ public void handleWBO(CryptoRecord record) {
+ ClientRecord r;
+ try {
+ r = (ClientRecord) factory.createRecord(record.decrypt());
+ downloadedClients.add(r);
+ numRecordsFromGetRequest++;
+ } catch (Exception e) {
+ fail("handleWBO failed.");
+ }
+ }
+ }
+
+ public class TestHandleWBODownloadDelegate extends TestClientDownloadDelegate {
+ public TestHandleWBODownloadDelegate(HTTPServerTestHelper data) {
+ super(data);
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ super.handleRequestFailure(response);
+ assertTrue(getMockDataAccessor().closed);
+ fail("Should not error.");
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ super.handleRequestError(ex);
+ assertTrue(getMockDataAccessor().closed);
+ ex.printStackTrace();
+ fail("Should not fail.");
+ }
+ }
+
+ public class MockSuccessClientUploadDelegate extends MockClientUploadDelegate {
+ public MockSuccessClientUploadDelegate(HTTPServerTestHelper data) {
+ super(data);
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ uploadHeaderTimestamp = response.normalizedWeaveTimestamp();
+ super.handleRequestSuccess(response);
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ super.handleRequestFailure(response);
+ fail("Should not fail.");
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ super.handleRequestError(ex);
+ ex.printStackTrace();
+ fail("Should not error.");
+ }
+ }
+
+ public class MockFailureClientUploadDelegate extends MockClientUploadDelegate {
+ public MockFailureClientUploadDelegate(HTTPServerTestHelper data) {
+ super(data);
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ super.handleRequestSuccess(response);
+ fail("Should not succeed.");
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ super.handleRequestError(ex);
+ fail("Should not fail.");
+ }
+ }
+
+ public class UploadMockServer extends MockServer {
+ @SuppressWarnings("unchecked")
+ private String postBodyForRecord(ClientRecord cr) {
+ final long now = cr.lastModified;
+ final BigDecimal modified = Utils.millisecondsToDecimalSeconds(now);
+
+ Logger.debug(LOG_TAG, "Now is " + now + " (" + modified + ")");
+ final JSONArray idArray = new JSONArray();
+ idArray.add(cr.guid);
+
+ final JSONObject result = new JSONObject();
+ result.put("modified", modified);
+ result.put("success", idArray);
+ result.put("failed", new JSONObject());
+
+ uploadBodyTimestamp = modified.toString();
+ return result.toJSONString();
+ }
+
+ private String putBodyForRecord(ClientRecord cr) {
+ final String modified = Utils.millisecondsToDecimalSecondsString(cr.lastModified);
+ uploadBodyTimestamp = modified;
+ return modified;
+ }
+
+ protected void handleUploadPUT(Request request, Response response) throws Exception {
+ Logger.debug(LOG_TAG, "Handling PUT: " + request.getPath());
+
+ // Save uploadedRecord to test against.
+ CryptoRecord cryptoRecord = CryptoRecord.fromJSONRecord(request.getContent());
+ cryptoRecord.keyBundle = session.keyBundleForCollection(COLLECTION_NAME);
+ uploadedRecord = (ClientRecord) factory.createRecord(cryptoRecord.decrypt());
+
+ // Note: collection is not saved in CryptoRecord.toJSONObject() upon upload.
+ // So its value is null and is set here so ClientRecord.equals() may be used.
+ uploadedRecord.collection = lastComputedLocalClientRecord.collection;
+
+ // Create response body containing current timestamp.
+ long now = System.currentTimeMillis();
+ PrintStream bodyStream = this.handleBasicHeaders(request, response, 200, "application/json", now);
+ uploadedRecord.lastModified = now;
+
+ bodyStream.println(putBodyForRecord(uploadedRecord));
+ bodyStream.close();
+ }
+
+ protected void handleUploadPOST(Request request, Response response) throws Exception {
+ Logger.debug(LOG_TAG, "Handling POST: " + request.getPath());
+ String content = request.getContent();
+ Logger.debug(LOG_TAG, "Content is " + content);
+ JSONArray array = ExtendedJSONObject.parseJSONArray(content);
+
+ Logger.debug(LOG_TAG, "Content is " + array);
+
+ KeyBundle keyBundle = session.keyBundleForCollection(COLLECTION_NAME);
+ if (array.size() != 1) {
+ Logger.debug(LOG_TAG, "Expecting only one record! Fail!");
+ PrintStream bodyStream = this.handleBasicHeaders(request, response, 400, "text/plain");
+ bodyStream.println("Expecting only one record! Fail!");
+ bodyStream.close();
+ return;
+ }
+
+ CryptoRecord r = CryptoRecord.fromJSONRecord(new ExtendedJSONObject((JSONObject) array.get(0)));
+ r.keyBundle = keyBundle;
+ ClientRecord cr = (ClientRecord) factory.createRecord(r.decrypt());
+ cr.collection = lastComputedLocalClientRecord.collection;
+ uploadedRecord = cr;
+
+ Logger.debug(LOG_TAG, "Record is " + cr);
+ long now = System.currentTimeMillis();
+ PrintStream bodyStream = this.handleBasicHeaders(request, response, 200, "application/json", now);
+ cr.lastModified = now;
+ final String responseBody = postBodyForRecord(cr);
+ Logger.debug(LOG_TAG, "Response is " + responseBody);
+ bodyStream.println(responseBody);
+ bodyStream.close();
+ }
+
+ @Override
+ public void handle(Request request, Response response) {
+ try {
+ String method = request.getMethod();
+ Logger.debug(LOG_TAG, "Handling " + method);
+ if (method.equalsIgnoreCase("post")) {
+ handleUploadPOST(request, response);
+ } else if (method.equalsIgnoreCase("put")) {
+ handleUploadPUT(request, response);
+ } else {
+ PrintStream bodyStream = this.handleBasicHeaders(request, response, 404, "text/plain");
+ bodyStream.close();
+ }
+ } catch (Exception e) {
+ fail("Error handling uploaded client record in UploadMockServer.");
+ }
+ }
+ }
+
+ public class DownloadMockServer extends MockServer {
+ @Override
+ public void handle(Request request, Response response) {
+ try {
+ PrintStream bodyStream = this.handleBasicHeaders(request, response, 200, "application/newlines");
+ for (int i = 0; i < 5; i++) {
+ ClientRecord record = new ClientRecord();
+ if (i != 2) { // So we test null version.
+ record.version = Integer.toString(28 + i);
+ }
+ expectedClients.add(record);
+ CryptoRecord cryptoRecord = cryptoFromClient(record);
+ bodyStream.print(cryptoRecord.toJSONString() + "\n");
+ }
+ bodyStream.close();
+ } catch (IOException e) {
+ fail("Error handling downloaded client records in DownloadMockServer.");
+ }
+ }
+ }
+
+ public class DownloadLocalRecordMockServer extends MockServer {
+ @SuppressWarnings("unchecked")
+ @Override
+ public void handle(Request request, Response response) {
+ try {
+ PrintStream bodyStream = this.handleBasicHeaders(request, response, 200, "application/newlines");
+ ClientRecord record = new ClientRecord(session.getClientsDelegate().getAccountGUID());
+
+ // Timestamp on server is 10 seconds after local timestamp
+ // (would trigger 412 if upload was attempted).
+ CryptoRecord cryptoRecord = cryptoFromClient(record);
+ JSONObject object = cryptoRecord.toJSONObject();
+ final long modified = (setRecentClientRecordTimestamp() + 10000) / 1000;
+ Logger.debug(LOG_TAG, "Setting modified to " + modified);
+ object.put("modified", modified);
+ bodyStream.print(object.toJSONString() + "\n");
+ bodyStream.close();
+ } catch (IOException e) {
+ fail("Error handling downloaded client records in DownloadLocalRecordMockServer.");
+ }
+ }
+ }
+
+ private CryptoRecord cryptoFromClient(ClientRecord record) {
+ CryptoRecord cryptoRecord = record.getEnvelope();
+ cryptoRecord.keyBundle = clientDownloadDelegate.keyBundle();
+ try {
+ cryptoRecord.encrypt();
+ } catch (Exception e) {
+ fail("Cannot encrypt client record.");
+ }
+ return cryptoRecord;
+ }
+
+ private long setRecentClientRecordTimestamp() {
+ long timestamp = System.currentTimeMillis() - (CLIENTS_TTL_REFRESH - 1000);
+ session.config.persistServerClientRecordTimestamp(timestamp);
+ return timestamp;
+ }
+
+ private void performFailingUpload() {
+ // performNotify() occurs in MockGlobalSessionCallback.
+ testWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ clientUploadDelegate = new MockFailureClientUploadDelegate(data);
+ checkAndUpload();
+ }
+ });
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void testShouldUploadNoCommandsToProcess() throws NullCursorException {
+ // shouldUpload() returns true.
+ assertEquals(0, session.config.getPersistedServerClientRecordTimestamp());
+ assertFalse(shouldUploadLocalRecord);
+ assertTrue(shouldUpload());
+
+ // Set the timestamp to be a little earlier than refresh time,
+ // so shouldUpload() returns false.
+ setRecentClientRecordTimestamp();
+ assertFalse(0 == session.config.getPersistedServerClientRecordTimestamp());
+ assertFalse(shouldUploadLocalRecord);
+ assertFalse(shouldUpload());
+
+ // Now simulate observing a client record with the incorrect version.
+
+ ClientRecord outdatedRecord = new ClientRecord("dontmatter12", "clients", System.currentTimeMillis(), false);
+
+ outdatedRecord.version = getLocalClientVersion();
+ outdatedRecord.protocols = getLocalClientProtocols();
+ handleDownloadedLocalRecord(outdatedRecord);
+
+ assertEquals(outdatedRecord.lastModified, session.config.getPersistedServerClientRecordTimestamp());
+ assertFalse(shouldUploadLocalRecord);
+ assertFalse(shouldUpload());
+
+ outdatedRecord.version = outdatedRecord.version + "a1";
+ handleDownloadedLocalRecord(outdatedRecord);
+
+ // Now we think we need to upload because the version is outdated.
+ assertTrue(shouldUploadLocalRecord);
+ assertTrue(shouldUpload());
+
+ shouldUploadLocalRecord = false;
+ assertFalse(shouldUpload());
+
+ // If the protocol list is missing or wrong, we should reupload.
+ outdatedRecord.protocols = new JSONArray();
+ handleDownloadedLocalRecord(outdatedRecord);
+ assertTrue(shouldUpload());
+
+ shouldUploadLocalRecord = false;
+ assertFalse(shouldUpload());
+
+ outdatedRecord.protocols.add("1.0");
+ handleDownloadedLocalRecord(outdatedRecord);
+ assertTrue(shouldUpload());
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void testShouldUploadProcessCommands() throws NullCursorException {
+ // shouldUpload() returns false since array is size 0 and
+ // it has not been long enough yet to require an upload.
+ processCommands(new JSONArray());
+ setRecentClientRecordTimestamp();
+ assertFalse(shouldUploadLocalRecord);
+ assertFalse(shouldUpload());
+
+ // shouldUpload() returns true since array is size 1 even though
+ // it has not been long enough yet to require an upload.
+ JSONArray commands = new JSONArray();
+ commands.add(new JSONObject());
+ processCommands(commands);
+ setRecentClientRecordTimestamp();
+ assertEquals(1, commands.size());
+ assertTrue(shouldUploadLocalRecord);
+ assertTrue(shouldUpload());
+ }
+
+ @Test
+ public void testWipeAndStoreShouldNotWipe() {
+ assertFalse(shouldWipe);
+ wipeAndStore(new ClientRecord());
+ assertFalse(shouldWipe);
+ assertFalse(getMockDataAccessor().clientsTableWiped);
+ assertTrue(getMockDataAccessor().storedRecord);
+ }
+
+ @Test
+ public void testWipeAndStoreShouldWipe() {
+ assertFalse(shouldWipe);
+ shouldWipe = true;
+ wipeAndStore(new ClientRecord());
+ assertFalse(shouldWipe);
+ assertTrue(getMockDataAccessor().clientsTableWiped);
+ assertTrue(getMockDataAccessor().storedRecord);
+ }
+
+ @Test
+ public void testDownloadClientRecord() {
+ // Make sure no upload occurs after a download so we can
+ // test download in isolation.
+ stubUpload = true;
+
+ currentDownloadMockServer = new DownloadMockServer();
+ // performNotify() occurs in MockGlobalSessionCallback.
+ testWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ clientDownloadDelegate = new TestSuccessClientDownloadDelegate(data);
+ downloadClientRecords();
+ }
+ });
+
+ assertEquals(expectedClients.size(), numRecordsFromGetRequest);
+ for (int i = 0; i < downloadedClients.size(); i++) {
+ final ClientRecord downloaded = downloadedClients.get(i);
+ final ClientRecord expected = expectedClients.get(i);
+ assertTrue(expected.guid.equals(downloaded.guid));
+ assertEquals(expected.version, downloaded.version);
+ }
+ assertTrue(mockDataAccessorIsClosed());
+ }
+
+ @Test
+ public void testCheckAndUploadClientRecord() {
+ uploadAttemptsCount.set(MAX_UPLOAD_FAILURE_COUNT);
+ assertFalse(shouldUploadLocalRecord);
+ assertEquals(0, session.config.getPersistedServerClientRecordTimestamp());
+ currentUploadMockServer = new UploadMockServer();
+ // performNotify() occurs in MockGlobalSessionCallback.
+ testWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ clientUploadDelegate = new MockSuccessClientUploadDelegate(data);
+ checkAndUpload();
+ }
+ });
+
+ // Test ClientUploadDelegate.handleRequestSuccess().
+ Logger.debug(LOG_TAG, "Last computed local client record: " + lastComputedLocalClientRecord.guid);
+ Logger.debug(LOG_TAG, "Uploaded client record: " + uploadedRecord.guid);
+ assertTrue(lastComputedLocalClientRecord.equalPayloads(uploadedRecord));
+ assertEquals(0, uploadAttemptsCount.get());
+ assertTrue(callback.calledSuccess);
+
+ assertFalse(0 == session.config.getPersistedServerClientRecordTimestamp());
+
+ // Body and header are the same.
+ assertEquals(Utils.decimalSecondsToMilliseconds(uploadBodyTimestamp),
+ session.config.getPersistedServerClientsTimestamp());
+ assertEquals(uploadedRecord.lastModified,
+ session.config.getPersistedServerClientRecordTimestamp());
+ assertEquals(uploadHeaderTimestamp, session.config.getPersistedServerClientsTimestamp());
+ }
+
+ @Test // client/server multi-threaded
+ public void testDownloadHasOurRecord() {
+ // Make sure no upload occurs after a download so we can
+ // test download in isolation.
+ stubUpload = true;
+
+ // We've uploaded our local record recently.
+ long initialTimestamp = setRecentClientRecordTimestamp();
+
+ currentDownloadMockServer = new DownloadLocalRecordMockServer();
+ // performNotify() occurs in MockGlobalSessionCallback.
+ testWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ clientDownloadDelegate = new TestHandleWBODownloadDelegate(data);
+ downloadClientRecords();
+ }
+ });
+
+ // Timestamp got updated (but not reset) since we downloaded our record
+ assertFalse(0 == session.config.getPersistedServerClientRecordTimestamp());
+ assertTrue(initialTimestamp < session.config.getPersistedServerClientRecordTimestamp());
+ assertTrue(mockDataAccessorIsClosed());
+ }
+
+ @Test
+ public void testResetTimestampOnDownload() {
+ // Make sure no upload occurs after a download so we can
+ // test download in isolation.
+ stubUpload = true;
+
+ currentDownloadMockServer = new DownloadMockServer();
+ // performNotify() occurs in MockGlobalSessionCallback.
+ testWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ clientDownloadDelegate = new TestHandleWBODownloadDelegate(data);
+ downloadClientRecords();
+ }
+ });
+
+ // Timestamp got reset since our record wasn't downloaded.
+ assertEquals(0, session.config.getPersistedServerClientRecordTimestamp());
+ assertTrue(mockDataAccessorIsClosed());
+ }
+
+ /**
+ * The following 8 tests are for ClientUploadDelegate.handleRequestFailure().
+ * for the varying values of uploadAttemptsCount, shouldUploadLocalRecord,
+ * and the type of server error.
+ *
+ * The first 4 are for 412 Precondition Failures.
+ * The second 4 represent the functionality given any other type of variable.
+ */
+ @Test
+ public void testHandle412UploadFailureLowCount() {
+ assertFalse(shouldUploadLocalRecord);
+ currentUploadMockServer = new MockServer(HttpStatus.SC_PRECONDITION_FAILED, null);
+ assertEquals(0, uploadAttemptsCount.get());
+ performFailingUpload();
+ assertEquals(0, uploadAttemptsCount.get());
+ assertTrue(callback.calledError);
+ }
+
+ @Test
+ public void testHandle412UploadFailureHighCount() {
+ assertFalse(shouldUploadLocalRecord);
+ currentUploadMockServer = new MockServer(HttpStatus.SC_PRECONDITION_FAILED, null);
+ uploadAttemptsCount.set(MAX_UPLOAD_FAILURE_COUNT);
+ performFailingUpload();
+ assertEquals(MAX_UPLOAD_FAILURE_COUNT, uploadAttemptsCount.get());
+ assertTrue(callback.calledError);
+ }
+
+ @Test
+ public void testHandle412UploadFailureLowCountWithCommand() {
+ shouldUploadLocalRecord = true;
+ currentUploadMockServer = new MockServer(HttpStatus.SC_PRECONDITION_FAILED, null);
+ assertEquals(0, uploadAttemptsCount.get());
+ performFailingUpload();
+ assertEquals(0, uploadAttemptsCount.get());
+ assertTrue(callback.calledError);
+ }
+
+ @Test
+ public void testHandle412UploadFailureHighCountWithCommand() {
+ shouldUploadLocalRecord = true;
+ currentUploadMockServer = new MockServer(HttpStatus.SC_PRECONDITION_FAILED, null);
+ uploadAttemptsCount.set(MAX_UPLOAD_FAILURE_COUNT);
+ performFailingUpload();
+ assertEquals(MAX_UPLOAD_FAILURE_COUNT, uploadAttemptsCount.get());
+ assertTrue(callback.calledError);
+ }
+
+ @Test
+ public void testHandleMiscUploadFailureLowCount() {
+ currentUploadMockServer = new MockServer(HttpStatus.SC_BAD_REQUEST, null);
+ assertFalse(shouldUploadLocalRecord);
+ assertEquals(0, uploadAttemptsCount.get());
+ performFailingUpload();
+ assertEquals(0, uploadAttemptsCount.get());
+ assertTrue(callback.calledError);
+ }
+
+ @Test
+ public void testHandleMiscUploadFailureHighCount() {
+ currentUploadMockServer = new MockServer(HttpStatus.SC_BAD_REQUEST, null);
+ assertFalse(shouldUploadLocalRecord);
+ uploadAttemptsCount.set(MAX_UPLOAD_FAILURE_COUNT);
+ performFailingUpload();
+ assertEquals(MAX_UPLOAD_FAILURE_COUNT, uploadAttemptsCount.get());
+ assertTrue(callback.calledError);
+ }
+
+ @Test
+ public void testHandleMiscUploadFailureHighCountWithCommands() {
+ currentUploadMockServer = new MockServer(HttpStatus.SC_BAD_REQUEST, null);
+ shouldUploadLocalRecord = true;
+ uploadAttemptsCount.set(MAX_UPLOAD_FAILURE_COUNT);
+ performFailingUpload();
+ assertEquals(MAX_UPLOAD_FAILURE_COUNT + 1, uploadAttemptsCount.get());
+ assertTrue(callback.calledError);
+ }
+
+ @Test
+ public void testHandleMiscUploadFailureMaxAttempts() {
+ currentUploadMockServer = new MockServer(HttpStatus.SC_BAD_REQUEST, null);
+ shouldUploadLocalRecord = true;
+ assertEquals(0, uploadAttemptsCount.get());
+ performFailingUpload();
+ assertEquals(MAX_UPLOAD_FAILURE_COUNT + 1, uploadAttemptsCount.get());
+ assertTrue(callback.calledError);
+ }
+
+ class TestAddCommandsMockClientsDatabaseAccessor extends MockClientsDatabaseAccessor {
+ @Override
+ public List<Command> fetchCommandsForClient(String accountGUID) throws NullCursorException {
+ List<Command> commands = new ArrayList<Command>();
+ commands.add(CommandHelpers.getCommand1());
+ commands.add(CommandHelpers.getCommand2());
+ commands.add(CommandHelpers.getCommand3());
+ commands.add(CommandHelpers.getCommand4());
+ return commands;
+ }
+ }
+
+ @Test
+ public void testAddCommandsToUnversionedClient() throws NullCursorException {
+ db = new TestAddCommandsMockClientsDatabaseAccessor();
+
+ final ClientRecord remoteRecord = new ClientRecord();
+ remoteRecord.version = null;
+ final String expectedGUID = remoteRecord.guid;
+
+ this.addCommands(remoteRecord);
+ assertEquals(1, modifiedClientsToUpload.size());
+
+ final ClientRecord recordToUpload = modifiedClientsToUpload.get(0);
+ assertEquals(4, recordToUpload.commands.size());
+ assertEquals(expectedGUID, recordToUpload.guid);
+ assertEquals(null, recordToUpload.version);
+ }
+
+ @Test
+ public void testAddCommandsToVersionedClient() throws NullCursorException {
+ db = new TestAddCommandsMockClientsDatabaseAccessor();
+
+ final ClientRecord remoteRecord = new ClientRecord();
+ remoteRecord.version = "12a1";
+ final String expectedGUID = remoteRecord.guid;
+
+ this.addCommands(remoteRecord);
+ assertEquals(1, modifiedClientsToUpload.size());
+
+ final ClientRecord recordToUpload = modifiedClientsToUpload.get(0);
+ assertEquals(4, recordToUpload.commands.size());
+ assertEquals(expectedGUID, recordToUpload.guid);
+ assertEquals("12a1", recordToUpload.version);
+ }
+
+ @Test
+ public void testLastModifiedTimestamp() throws NullCursorException {
+ // If we uploaded a record a moment ago, we shouldn't upload another.
+ final long now = System.currentTimeMillis() - 1;
+ session.config.persistServerClientRecordTimestamp(now);
+ assertEquals(now, session.config.getPersistedServerClientRecordTimestamp());
+ assertFalse(shouldUploadLocalRecord);
+ assertFalse(shouldUpload());
+
+ // But if we change our client data, we should upload.
+ session.getClientsDelegate().setClientName("new name", System.currentTimeMillis());
+ assertTrue(shouldUpload());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestCredentialsEndToEnd.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestCredentialsEndToEnd.java
new file mode 100644
index 000000000..0f568a81e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestCredentialsEndToEnd.java
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+
+import ch.boye.httpclientandroidlib.Header;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Test the transfer of a UTF-8 string from desktop, and ensure that it results in the
+ * correct hashed Basic Auth header.
+ */
+@RunWith(TestRunner.class)
+public class TestCredentialsEndToEnd {
+
+ public static final String REAL_PASSWORD = "pïgéons1";
+ public static final String USERNAME = "utvm3mk6hnngiir2sp4jsxf2uvoycrv6";
+ public static final String DESKTOP_PASSWORD_JSON = "{\"password\":\"pïgéons1\"}";
+ public static final String BTOA_PASSWORD = "cMOvZ8Opb25zMQ==";
+ public static final int DESKTOP_ASSERTED_SIZE = 10;
+ public static final String DESKTOP_BASIC_AUTH = "Basic dXR2bTNtazZobm5naWlyMnNwNGpzeGYydXZveWNydjY6cMOvZ8Opb25zMQ==";
+
+ private String getCreds(String password) {
+ Header authenticate = new BasicAuthHeaderProvider(USERNAME, password).getAuthHeader(null, null, null);
+ return authenticate.getValue();
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testUTF8() throws UnsupportedEncodingException {
+ final String in = "pïgéons1";
+ final String out = "pïgéons1";
+ assertEquals(out, Utils.decodeUTF8(in));
+ }
+
+ @Test
+ public void testAuthHeaderFromPassword() throws NonObjectJSONException, IOException {
+ final ExtendedJSONObject parsed = new ExtendedJSONObject(DESKTOP_PASSWORD_JSON);
+
+ final String password = parsed.getString("password");
+ final String decoded = Utils.decodeUTF8(password);
+
+ final byte[] expectedBytes = Utils.decodeBase64(BTOA_PASSWORD);
+ final String expected = new String(expectedBytes, "UTF-8");
+
+ assertEquals(DESKTOP_ASSERTED_SIZE, password.length());
+ assertEquals(expected, decoded);
+
+ System.out.println("Retrieved password: " + password);
+ System.out.println("Expected password: " + expected);
+ System.out.println("Rescued password: " + decoded);
+
+ assertEquals(getCreds(expected), getCreds(decoded));
+ assertEquals(getCreds(decoded), DESKTOP_BASIC_AUTH);
+ }
+
+ // Note that we do *not* have a test for the J-PAKE setup process
+ // (SetupSyncActivity) that actually stores credentials and requires
+ // decodeUTF8. This will have to suffice.
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestGlobalSession.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestGlobalSession.java
new file mode 100644
index 000000000..c00da9b26
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestGlobalSession.java
@@ -0,0 +1,436 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+import ch.boye.httpclientandroidlib.message.BasicStatusLine;
+import junit.framework.AssertionFailedError;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockGlobalSessionCallback;
+import org.mozilla.android.sync.test.helpers.MockResourceDelegate;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.MockAbstractNonRepositorySyncStage;
+import org.mozilla.gecko.background.testhelpers.MockGlobalSession;
+import org.mozilla.gecko.background.testhelpers.MockPrefsGlobalSession;
+import org.mozilla.gecko.background.testhelpers.MockServerSyncStage;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.EngineSettings;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.MetaGlobal;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.SyncConfigurationException;
+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.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
+import org.mozilla.gecko.sync.stage.AndroidBrowserBookmarksServerSyncStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+import org.mozilla.gecko.sync.stage.NoSuchStageException;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+@RunWith(TestRunner.class)
+public class TestGlobalSession {
+ private int TEST_PORT = HTTPServerTestHelper.getTestPort();
+ private final String TEST_CLUSTER_URL = "http://localhost:" + TEST_PORT;
+ private final String TEST_USERNAME = "johndoe";
+ private final String TEST_PASSWORD = "password";
+ private final String TEST_SYNC_KEY = "abcdeabcdeabcdeabcdeabcdea";
+ private final long TEST_BACKOFF_IN_SECONDS = 2401;
+
+ public static WaitHelper getTestWaiter() {
+ return WaitHelper.getTestWaiter();
+ }
+
+ @Test
+ public void testGetSyncStagesBy() throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, CryptoException, NoSuchStageException {
+
+ final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+ GlobalSession s = MockPrefsGlobalSession.getSession(TEST_USERNAME, TEST_PASSWORD,
+ new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY),
+ callback, /* context */ null, null);
+
+ assertTrue(s.getSyncStageByName(Stage.syncBookmarks) instanceof AndroidBrowserBookmarksServerSyncStage);
+
+ final Set<String> empty = new HashSet<String>();
+
+ final Set<String> bookmarksAndTabsNames = new HashSet<String>();
+ bookmarksAndTabsNames.add("bookmarks");
+ bookmarksAndTabsNames.add("tabs");
+
+ final Set<GlobalSyncStage> bookmarksAndTabsSyncStages = new HashSet<GlobalSyncStage>();
+ GlobalSyncStage bookmarksStage = s.getSyncStageByName("bookmarks");
+ GlobalSyncStage tabsStage = s.getSyncStageByName(Stage.syncTabs);
+ bookmarksAndTabsSyncStages.add(bookmarksStage);
+ bookmarksAndTabsSyncStages.add(tabsStage);
+
+ final Set<Stage> bookmarksAndTabsEnums = new HashSet<Stage>();
+ bookmarksAndTabsEnums.add(Stage.syncBookmarks);
+ bookmarksAndTabsEnums.add(Stage.syncTabs);
+
+ assertTrue(s.getSyncStagesByName(empty).isEmpty());
+ assertEquals(bookmarksAndTabsSyncStages, new HashSet<GlobalSyncStage>(s.getSyncStagesByName(bookmarksAndTabsNames)));
+ assertEquals(bookmarksAndTabsSyncStages, new HashSet<GlobalSyncStage>(s.getSyncStagesByEnum(bookmarksAndTabsEnums)));
+ }
+
+ /**
+ * Test that handleHTTPError does in fact backoff.
+ */
+ @Test
+ public void testBackoffCalledByHandleHTTPError() {
+ try {
+ final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+ SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+ final GlobalSession session = new MockGlobalSession(config, callback);
+
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+ response.setHeader("X-Weave-Backoff", Long.toString(TEST_BACKOFF_IN_SECONDS)); // Backoff given in seconds.
+
+ getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+ @Override
+ public void run() {
+ session.handleHTTPError(new SyncStorageResponse(response), "Illegal method/protocol");
+ }
+ }));
+
+ assertEquals(false, callback.calledSuccess);
+ assertEquals(true, callback.calledError);
+ assertEquals(false, callback.calledAborted);
+ assertEquals(true, callback.calledRequestBackoff);
+ assertEquals(TEST_BACKOFF_IN_SECONDS * 1000, callback.weaveBackoff); // Backoff returned in milliseconds.
+ } catch (Exception e) {
+ e.printStackTrace();
+ fail("Got exception.");
+ }
+ }
+
+ /**
+ * Test that a trivially successful GlobalSession does not fail or backoff.
+ */
+ @Test
+ public void testSuccessCalledAfterStages() {
+ try {
+ final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+ SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+ final GlobalSession session = new MockGlobalSession(config, callback);
+
+ getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ session.start();
+ } catch (Exception e) {
+ final AssertionFailedError error = new AssertionFailedError();
+ error.initCause(e);
+ getTestWaiter().performNotify(error);
+ }
+ }
+ }));
+
+ assertEquals(true, callback.calledSuccess);
+ assertEquals(false, callback.calledError);
+ assertEquals(false, callback.calledAborted);
+ assertEquals(false, callback.calledRequestBackoff);
+ } catch (Exception e) {
+ e.printStackTrace();
+ fail("Got exception.");
+ }
+ }
+
+ /**
+ * Test that a failing GlobalSession does in fact fail and back off.
+ */
+ @Test
+ public void testBackoffCalledInStages() {
+ try {
+ final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+
+ // Stage fakes a 503 and sets X-Weave-Backoff header to the given seconds.
+ final GlobalSyncStage stage = new MockAbstractNonRepositorySyncStage() {
+ @Override
+ public void execute() {
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+
+ response.addHeader("X-Weave-Backoff", Long.toString(TEST_BACKOFF_IN_SECONDS)); // Backoff given in seconds.
+ session.handleHTTPError(new SyncStorageResponse(response), "Failure fetching info/collections.");
+ }
+ };
+
+ // Session installs fake stage to fetch info/collections.
+ SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+ final GlobalSession session = new MockGlobalSession(config, callback)
+ .withStage(Stage.fetchInfoCollections, stage);
+
+ getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ session.start();
+ } catch (Exception e) {
+ final AssertionFailedError error = new AssertionFailedError();
+ error.initCause(e);
+ getTestWaiter().performNotify(error);
+ }
+ }
+ }));
+
+ assertEquals(false, callback.calledSuccess);
+ assertEquals(true, callback.calledError);
+ assertEquals(false, callback.calledAborted);
+ assertEquals(true, callback.calledRequestBackoff);
+ assertEquals(TEST_BACKOFF_IN_SECONDS * 1000, callback.weaveBackoff); // Backoff returned in milliseconds.
+ } catch (Exception e) {
+ e.printStackTrace();
+ fail("Got exception.");
+ }
+ }
+
+ private HTTPServerTestHelper data = new HTTPServerTestHelper();
+
+ @SuppressWarnings("static-method")
+ @Before
+ public void setUp() {
+ BaseResource.rewriteLocalhost = false;
+ }
+
+ public void doRequest() {
+ final WaitHelper innerWaitHelper = new WaitHelper();
+ innerWaitHelper.performWait(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ final BaseResource r = new BaseResource(TEST_CLUSTER_URL);
+ r.delegate = new MockResourceDelegate(innerWaitHelper);
+ r.get();
+ } catch (URISyntaxException e) {
+ innerWaitHelper.performNotify(e);
+ }
+ }
+ });
+ }
+
+ public MockGlobalSessionCallback doTestSuccess(final boolean stageShouldBackoff, final boolean stageShouldAdvance) throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, CryptoException {
+ MockServer server = new MockServer() {
+ @Override
+ public void handle(Request request, Response response) {
+ if (stageShouldBackoff) {
+ response.addValue("X-Weave-Backoff", Long.toString(TEST_BACKOFF_IN_SECONDS));
+ }
+ super.handle(request, response);
+ }
+ };
+
+ final MockServerSyncStage stage = new MockServerSyncStage() {
+ @Override
+ public void execute() {
+ // We should have installed our HTTP response observer before starting the sync.
+ assertTrue(BaseResource.isHttpResponseObserver(session));
+
+ doRequest();
+ if (stageShouldAdvance) {
+ session.advance();
+ return;
+ }
+ session.abort(null, "Stage intentionally failed.");
+ }
+ };
+
+ final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+ SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+ final GlobalSession session = new MockGlobalSession(config, callback)
+ .withStage(Stage.syncBookmarks, stage);
+
+ data.startHTTPServer(server);
+ WaitHelper.getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ session.start();
+ } catch (Exception e) {
+ final AssertionFailedError error = new AssertionFailedError();
+ error.initCause(e);
+ WaitHelper.getTestWaiter().performNotify(error);
+ }
+ }
+ }));
+ data.stopHTTPServer();
+
+ // We should have uninstalled our HTTP response observer when the session is terminated.
+ assertFalse(BaseResource.isHttpResponseObserver(session));
+
+ return callback;
+ }
+
+ @Test
+ public void testOnSuccessBackoffAdvanced() throws SyncConfigurationException,
+ IllegalArgumentException, NonObjectJSONException, IOException,
+ CryptoException {
+ MockGlobalSessionCallback callback = doTestSuccess(true, true);
+
+ assertTrue(callback.calledError); // TODO: this should be calledAborted.
+ assertTrue(callback.calledRequestBackoff);
+ assertEquals(1000 * TEST_BACKOFF_IN_SECONDS, callback.weaveBackoff);
+ }
+
+ @Test
+ public void testOnSuccessBackoffAborted() throws SyncConfigurationException,
+ IllegalArgumentException, NonObjectJSONException, IOException,
+ CryptoException {
+ MockGlobalSessionCallback callback = doTestSuccess(true, false);
+
+ assertTrue(callback.calledError); // TODO: this should be calledAborted.
+ assertTrue(callback.calledRequestBackoff);
+ assertEquals(1000 * TEST_BACKOFF_IN_SECONDS, callback.weaveBackoff);
+ }
+
+ @Test
+ public void testOnSuccessNoBackoffAdvanced() throws SyncConfigurationException,
+ IllegalArgumentException, NonObjectJSONException, IOException,
+ CryptoException {
+ MockGlobalSessionCallback callback = doTestSuccess(false, true);
+
+ assertTrue(callback.calledSuccess);
+ assertFalse(callback.calledRequestBackoff);
+ }
+
+ @Test
+ public void testOnSuccessNoBackoffAborted() throws SyncConfigurationException,
+ IllegalArgumentException, NonObjectJSONException, IOException,
+ CryptoException {
+ MockGlobalSessionCallback callback = doTestSuccess(false, false);
+
+ assertTrue(callback.calledError); // TODO: this should be calledAborted.
+ assertFalse(callback.calledRequestBackoff);
+ }
+
+ @Test
+ public void testGenerateNewMetaGlobalNonePersisted() throws Exception {
+ final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+ final GlobalSession session = MockPrefsGlobalSession.getSession(TEST_USERNAME, TEST_PASSWORD,
+ new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY), callback, null, null);
+
+ // Verify we fill in all of our known engines when none are persisted.
+ session.config.enabledEngineNames = null;
+ MetaGlobal mg = session.generateNewMetaGlobal();
+ assertEquals(Long.valueOf(GlobalSession.STORAGE_VERSION), mg.getStorageVersion());
+ assertEquals(VersionConstants.BOOKMARKS_ENGINE_VERSION, mg.getEngines().getObject("bookmarks").getIntegerSafely("version").intValue());
+ assertEquals(VersionConstants.CLIENTS_ENGINE_VERSION, mg.getEngines().getObject("clients").getIntegerSafely("version").intValue());
+
+ List<String> namesList = new ArrayList<String>(mg.getEnabledEngineNames());
+ Collections.sort(namesList);
+ String[] names = namesList.toArray(new String[namesList.size()]);
+ String[] expected = new String[] { "bookmarks", "clients", "forms", "history", "passwords", "tabs" };
+ assertArrayEquals(expected, names);
+ }
+
+ @Test
+ public void testGenerateNewMetaGlobalSomePersisted() throws Exception {
+ final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+ final GlobalSession session = MockPrefsGlobalSession.getSession(TEST_USERNAME, TEST_PASSWORD,
+ new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY), callback, null, null);
+
+ // Verify we preserve engines with version 0 if some are persisted.
+ session.config.enabledEngineNames = new HashSet<String>();
+ session.config.enabledEngineNames.add("bookmarks");
+ session.config.enabledEngineNames.add("clients");
+ session.config.enabledEngineNames.add("addons");
+ session.config.enabledEngineNames.add("prefs");
+
+ MetaGlobal mg = session.generateNewMetaGlobal();
+ assertEquals(Long.valueOf(GlobalSession.STORAGE_VERSION), mg.getStorageVersion());
+ assertEquals(VersionConstants.BOOKMARKS_ENGINE_VERSION, mg.getEngines().getObject("bookmarks").getIntegerSafely("version").intValue());
+ assertEquals(VersionConstants.CLIENTS_ENGINE_VERSION, mg.getEngines().getObject("clients").getIntegerSafely("version").intValue());
+ assertEquals(0, mg.getEngines().getObject("addons").getIntegerSafely("version").intValue());
+ assertEquals(0, mg.getEngines().getObject("prefs").getIntegerSafely("version").intValue());
+
+ List<String> namesList = new ArrayList<String>(mg.getEnabledEngineNames());
+ Collections.sort(namesList);
+ String[] names = namesList.toArray(new String[namesList.size()]);
+ String[] expected = new String[] { "addons", "bookmarks", "clients", "prefs" };
+ assertArrayEquals(expected, names);
+ }
+
+ @Test
+ public void testUploadUpdatedMetaGlobal() throws Exception {
+ // Set up session with meta/global.
+ final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+ final GlobalSession session = MockPrefsGlobalSession.getSession(TEST_USERNAME, TEST_PASSWORD,
+ new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY), callback, null, null);
+ session.config.metaGlobal = session.generateNewMetaGlobal();
+ session.enginesToUpdate.clear();
+
+ // Set enabledEngines in meta/global, including a "new engine."
+ String[] origEngines = new String[] { "bookmarks", "clients", "forms", "history", "tabs", "new-engine" };
+
+ ExtendedJSONObject origEnginesJSONObject = new ExtendedJSONObject();
+ for (String engineName : origEngines) {
+ EngineSettings mockEngineSettings = new EngineSettings(Utils.generateGuid(), Integer.valueOf(0));
+ origEnginesJSONObject.put(engineName, mockEngineSettings.toJSONObject());
+ }
+ session.config.metaGlobal.setEngines(origEnginesJSONObject);
+
+ // Engines to remove.
+ String[] toRemove = new String[] { "bookmarks", "tabs" };
+ for (String name : toRemove) {
+ session.removeEngineFromMetaGlobal(name);
+ }
+
+ // Engines to add.
+ String[] toAdd = new String[] { "passwords" };
+ for (String name : toAdd) {
+ String syncId = Utils.generateGuid();
+ session.recordForMetaGlobalUpdate(name, new EngineSettings(syncId, Integer.valueOf(1)));
+ }
+
+ // Update engines.
+ session.uploadUpdatedMetaGlobal();
+
+ // Check resulting enabledEngines.
+ Set<String> expected = new HashSet<String>();
+ for (String name : origEngines) {
+ expected.add(name);
+ }
+ for (String name : toRemove) {
+ expected.remove(name);
+ }
+ for (String name : toAdd) {
+ expected.add(name);
+ }
+ assertEquals(expected, session.config.metaGlobal.getEnabledEngineNames());
+ }
+
+ public void testStageAdvance() {
+ assertEquals(GlobalSession.nextStage(Stage.idle), Stage.checkPreconditions);
+ assertEquals(GlobalSession.nextStage(Stage.completed), Stage.idle);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestHeaderParsing.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestHeaderParsing.java
new file mode 100644
index 000000000..532d60d13
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestHeaderParsing.java
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.Utils;
+
+import static org.junit.Assert.assertEquals;
+
+@RunWith(TestRunner.class)
+public class TestHeaderParsing {
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testDecimalSecondsToMilliseconds() {
+ assertEquals(Utils.decimalSecondsToMilliseconds(""), -1);
+ assertEquals(Utils.decimalSecondsToMilliseconds("1234.1.1"), -1);
+ assertEquals(Utils.decimalSecondsToMilliseconds("1234"), 1234000);
+ assertEquals(Utils.decimalSecondsToMilliseconds("1234.123"), 1234123);
+ assertEquals(Utils.decimalSecondsToMilliseconds("1234.12"), 1234120);
+
+ assertEquals("1234.000", Utils.millisecondsToDecimalSecondsString(1234000));
+ assertEquals("1234.123", Utils.millisecondsToDecimalSecondsString(1234123));
+ assertEquals("1234.120", Utils.millisecondsToDecimalSecondsString(1234120));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestLineByLineHandling.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestLineByLineHandling.java
new file mode 100644
index 000000000..b7f11adbf
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestLineByLineHandling.java
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+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.SyncStorageCollectionRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+@RunWith(TestRunner.class)
+public class TestLineByLineHandling {
+ private static final int TEST_PORT = HTTPServerTestHelper.getTestPort();
+ private static final String TEST_SERVER = "http://localhost:" + TEST_PORT;
+ private static final String LOG_TAG = "TestLineByLineHandling";
+ static String STORAGE_URL = TEST_SERVER + "/1.1/c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd/storage/lines";
+ private HTTPServerTestHelper data = new HTTPServerTestHelper();
+
+ public ArrayList<String> lines = new ArrayList<String>();
+
+ public class LineByLineMockServer extends MockServer {
+ public void handle(Request request, Response response) {
+ try {
+ System.out.println("Handling line-by-line request...");
+ PrintStream bodyStream = this.handleBasicHeaders(request, response, 200, "application/newlines");
+
+ bodyStream.print("First line.\n");
+ bodyStream.print("Second line.\n");
+ bodyStream.print("Third line.\n");
+ bodyStream.print("Fourth line.\n");
+ bodyStream.close();
+ } catch (IOException e) {
+ System.err.println("Oops.");
+ }
+ }
+ }
+
+ public class BaseLineByLineDelegate extends
+ SyncStorageCollectionRequestDelegate {
+
+ @Override
+ public void handleRequestProgress(String progress) {
+ lines.add(progress);
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return null;
+ }
+
+ @Override
+ public String ifUnmodifiedSince() {
+ return null;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse res) {
+ Logger.info(LOG_TAG, "Request success.");
+ assertTrue(res.wasSuccessful());
+ assertTrue(res.httpResponse().containsHeader("X-Weave-Timestamp"));
+
+ assertEquals(lines.size(), 4);
+ assertEquals(lines.get(0), "First line.");
+ assertEquals(lines.get(1), "Second line.");
+ assertEquals(lines.get(2), "Third line.");
+ assertEquals(lines.get(3), "Fourth line.");
+ data.stopHTTPServer();
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ Logger.info(LOG_TAG, "Got request failure: " + response);
+ BaseResource.consumeEntity(response);
+ fail("Should not be called.");
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ Logger.error(LOG_TAG, "Got request error: ", ex);
+ fail("Should not be called.");
+ }
+ }
+
+ @Test
+ public void testLineByLine() throws URISyntaxException {
+ BaseResource.rewriteLocalhost = false;
+
+ data.startHTTPServer(new LineByLineMockServer());
+ Logger.info(LOG_TAG, "Server started.");
+ SyncStorageCollectionRequest r = new SyncStorageCollectionRequest(new URI(STORAGE_URL));
+ SyncStorageCollectionRequestDelegate delegate = new BaseLineByLineDelegate();
+ r.delegate = delegate;
+ r.get();
+ // Server is stopped in the callback.
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestMetaGlobal.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestMetaGlobal.java
new file mode 100644
index 000000000..ec4c03859
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestMetaGlobal.java
@@ -0,0 +1,347 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.MetaGlobal;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.delegates.MetaGlobalDelegate;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestMetaGlobal {
+ private static final int TEST_PORT = HTTPServerTestHelper.getTestPort();
+ private static final String TEST_SERVER = "http://localhost:" + TEST_PORT;
+ private static final String TEST_SYNC_ID = "foobar";
+
+ public static final String USER_PASS = "c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd:password";
+ public static final String META_URL = TEST_SERVER + "/1.1/c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd/storage/meta/global";
+ private HTTPServerTestHelper data = new HTTPServerTestHelper();
+
+
+ public static final String TEST_DECLINED_META_GLOBAL_RESPONSE =
+ "{\"id\":\"global\"," +
+ "\"payload\":" +
+ "\"{\\\"syncID\\\":\\\"zPSQTm7WBVWB\\\"," +
+ "\\\"declined\\\":[\\\"bookmarks\\\"]," +
+ "\\\"storageVersion\\\":5," +
+ "\\\"engines\\\":{" +
+ "\\\"clients\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"fDg0MS5bDtV7\\\"}," +
+ "\\\"forms\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"GXF29AFprnvc\\\"}," +
+ "\\\"history\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"av75g4vm-_rp\\\"}," +
+ "\\\"passwords\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"LT_ACGpuKZ6a\\\"}," +
+ "\\\"prefs\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"-3nsksP9wSAs\\\"}," +
+ "\\\"tabs\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"W4H5lOMChkYA\\\"}}}\"," +
+ "\"username\":\"5817483\"," +
+ "\"modified\":1.32046073744E9}";
+
+ public static final String TEST_META_GLOBAL_RESPONSE =
+ "{\"id\":\"global\"," +
+ "\"payload\":" +
+ "\"{\\\"syncID\\\":\\\"zPSQTm7WBVWB\\\"," +
+ "\\\"storageVersion\\\":5," +
+ "\\\"engines\\\":{" +
+ "\\\"clients\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"fDg0MS5bDtV7\\\"}," +
+ "\\\"bookmarks\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"NNaQr6_F-9dm\\\"}," +
+ "\\\"forms\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"GXF29AFprnvc\\\"}," +
+ "\\\"history\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"av75g4vm-_rp\\\"}," +
+ "\\\"passwords\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"LT_ACGpuKZ6a\\\"}," +
+ "\\\"prefs\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"-3nsksP9wSAs\\\"}," +
+ "\\\"tabs\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"W4H5lOMChkYA\\\"}}}\"," +
+ "\"username\":\"5817483\"," +
+ "\"modified\":1.32046073744E9}";
+ public static final String TEST_META_GLOBAL_NO_PAYLOAD_RESPONSE = "{\"id\":\"global\"," +
+ "\"username\":\"5817483\",\"modified\":1.32046073744E9}";
+ public static final String TEST_META_GLOBAL_MALFORMED_PAYLOAD_RESPONSE = "{\"id\":\"global\"," +
+ "\"payload\":\"{!!!}\"," +
+ "\"username\":\"5817483\",\"modified\":1.32046073744E9}";
+ public static final String TEST_META_GLOBAL_EMPTY_PAYLOAD_RESPONSE = "{\"id\":\"global\"," +
+ "\"payload\":\"{}\"," +
+ "\"username\":\"5817483\",\"modified\":1.32046073744E9}";
+
+ public MetaGlobal global;
+
+ @SuppressWarnings("static-method")
+ @Before
+ public void setUp() {
+ BaseResource.rewriteLocalhost = false;
+ global = new MetaGlobal(META_URL, new BasicAuthHeaderProvider(USER_PASS));
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testSyncID() {
+ global.setSyncID("foobar");
+ assertEquals(global.getSyncID(), "foobar");
+ }
+
+ public class MockMetaGlobalFetchDelegate implements MetaGlobalDelegate {
+ boolean successCalled = false;
+ MetaGlobal successGlobal = null;
+ SyncStorageResponse successResponse = null;
+ boolean failureCalled = false;
+ SyncStorageResponse failureResponse = null;
+ boolean errorCalled = false;
+ Exception errorException = null;
+ boolean missingCalled = false;
+ MetaGlobal missingGlobal = null;
+ SyncStorageResponse missingResponse = null;
+
+ public void handleSuccess(MetaGlobal global, SyncStorageResponse response) {
+ successCalled = true;
+ successGlobal = global;
+ successResponse = response;
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ public void handleFailure(SyncStorageResponse response) {
+ failureCalled = true;
+ failureResponse = response;
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ public void handleError(Exception e) {
+ errorCalled = true;
+ errorException = e;
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ public void handleMissing(MetaGlobal global, SyncStorageResponse response) {
+ missingCalled = true;
+ missingGlobal = global;
+ missingResponse = response;
+ WaitHelper.getTestWaiter().performNotify();
+ }
+ }
+
+ public MockMetaGlobalFetchDelegate doFetch(final MetaGlobal global) {
+ final MockMetaGlobalFetchDelegate delegate = new MockMetaGlobalFetchDelegate();
+ WaitHelper.getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+ @Override
+ public void run() {
+ global.fetch(delegate);
+ }
+ }));
+
+ return delegate;
+ }
+
+ @Test
+ public void testFetchMissing() {
+ MockServer missingMetaGlobalServer = new MockServer(404, "{}");
+ global.setSyncID(TEST_SYNC_ID);
+ assertEquals(TEST_SYNC_ID, global.getSyncID());
+
+ data.startHTTPServer(missingMetaGlobalServer);
+ final MockMetaGlobalFetchDelegate delegate = doFetch(global);
+ data.stopHTTPServer();
+
+ assertTrue(delegate.missingCalled);
+ assertEquals(404, delegate.missingResponse.getStatusCode());
+ assertEquals(TEST_SYNC_ID, delegate.missingGlobal.getSyncID());
+ }
+
+ @Test
+ public void testFetchExisting() {
+ MockServer existingMetaGlobalServer = new MockServer(200, TEST_META_GLOBAL_RESPONSE);
+ assertNull(global.getSyncID());
+ assertNull(global.getEngines());
+ assertNull(global.getStorageVersion());
+
+ data.startHTTPServer(existingMetaGlobalServer);
+ final MockMetaGlobalFetchDelegate delegate = doFetch(global);
+ data.stopHTTPServer();
+
+ assertTrue(delegate.successCalled);
+ assertEquals(200, delegate.successResponse.getStatusCode());
+ assertEquals("zPSQTm7WBVWB", global.getSyncID());
+ assertTrue(global.getEngines() instanceof ExtendedJSONObject);
+ assertEquals(Long.valueOf(5), global.getStorageVersion());
+ }
+
+ /**
+ * A record that is valid JSON but invalid as a meta/global record will be
+ * downloaded successfully, but will fail later.
+ */
+ @Test
+ public void testFetchNoPayload() {
+ MockServer existingMetaGlobalServer = new MockServer(200, TEST_META_GLOBAL_NO_PAYLOAD_RESPONSE);
+
+ data.startHTTPServer(existingMetaGlobalServer);
+ final MockMetaGlobalFetchDelegate delegate = doFetch(global);
+ data.stopHTTPServer();
+
+ assertTrue(delegate.successCalled);
+ }
+
+ @Test
+ public void testFetchEmptyPayload() {
+ MockServer existingMetaGlobalServer = new MockServer(200, TEST_META_GLOBAL_EMPTY_PAYLOAD_RESPONSE);
+
+ data.startHTTPServer(existingMetaGlobalServer);
+ final MockMetaGlobalFetchDelegate delegate = doFetch(global);
+ data.stopHTTPServer();
+
+ assertTrue(delegate.successCalled);
+ }
+
+ /**
+ * A record that is invalid JSON will fail to download at all.
+ */
+ @Test
+ public void testFetchMalformedPayload() {
+ MockServer existingMetaGlobalServer = new MockServer(200, TEST_META_GLOBAL_MALFORMED_PAYLOAD_RESPONSE);
+
+ data.startHTTPServer(existingMetaGlobalServer);
+ final MockMetaGlobalFetchDelegate delegate = doFetch(global);
+ data.stopHTTPServer();
+
+ assertTrue(delegate.errorCalled);
+ assertNotNull(delegate.errorException);
+ assertEquals(NonObjectJSONException.class, delegate.errorException.getClass());
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testSetFromRecord() throws Exception {
+ MetaGlobal mg = new MetaGlobal(null, null);
+ mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_META_GLOBAL_RESPONSE));
+ assertEquals("zPSQTm7WBVWB", mg.getSyncID());
+ assertTrue(mg.getEngines() instanceof ExtendedJSONObject);
+ assertEquals(Long.valueOf(5), mg.getStorageVersion());
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testAsCryptoRecord() throws Exception {
+ MetaGlobal mg = new MetaGlobal(null, null);
+ mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_META_GLOBAL_RESPONSE));
+ CryptoRecord rec = mg.asCryptoRecord();
+ assertEquals("global", rec.guid);
+ mg.setFromRecord(rec);
+ assertEquals("zPSQTm7WBVWB", mg.getSyncID());
+ assertTrue(mg.getEngines() instanceof ExtendedJSONObject);
+ assertEquals(Long.valueOf(5), mg.getStorageVersion());
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testGetEnabledEngineNames() throws Exception {
+ MetaGlobal mg = new MetaGlobal(null, null);
+ mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_META_GLOBAL_RESPONSE));
+ assertEquals("zPSQTm7WBVWB", mg.getSyncID());
+ final Set<String> actual = mg.getEnabledEngineNames();
+ final Set<String> expected = new HashSet<String>();
+ for (String name : new String[] { "bookmarks", "clients", "forms", "history", "passwords", "prefs", "tabs" }) {
+ expected.add(name);
+ }
+ assertEquals(expected, actual);
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testGetEmptyDeclinedEngineNames() throws Exception {
+ MetaGlobal mg = new MetaGlobal(null, null);
+ mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_META_GLOBAL_RESPONSE));
+ assertEquals(0, mg.getDeclinedEngineNames().size());
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testGetDeclinedEngineNames() throws Exception {
+ MetaGlobal mg = new MetaGlobal(null, null);
+ mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_DECLINED_META_GLOBAL_RESPONSE));
+ assertEquals(1, mg.getDeclinedEngineNames().size());
+ assertEquals("bookmarks", mg.getDeclinedEngineNames().iterator().next());
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testRoundtripDeclinedEngineNames() throws Exception {
+ MetaGlobal mg = new MetaGlobal(null, null);
+ mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_DECLINED_META_GLOBAL_RESPONSE));
+ assertEquals("bookmarks", mg.getDeclinedEngineNames().iterator().next());
+ assertEquals("bookmarks", mg.asCryptoRecord().payload.getArray("declined").get(0));
+ MetaGlobal again = new MetaGlobal(null, null);
+ again.setFromRecord(mg.asCryptoRecord());
+ assertEquals("bookmarks", again.getDeclinedEngineNames().iterator().next());
+ }
+
+
+ public MockMetaGlobalFetchDelegate doUpload(final MetaGlobal global) {
+ final MockMetaGlobalFetchDelegate delegate = new MockMetaGlobalFetchDelegate();
+ WaitHelper.getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+ @Override
+ public void run() {
+ global.upload(delegate);
+ }
+ }));
+
+ return delegate;
+ }
+
+ @Test
+ public void testUpload() {
+ long TEST_STORAGE_VERSION = 111;
+ String TEST_SYNC_ID = "testSyncID";
+ global.setSyncID(TEST_SYNC_ID);
+ global.setStorageVersion(Long.valueOf(TEST_STORAGE_VERSION));
+
+ final AtomicBoolean mgUploaded = new AtomicBoolean(false);
+ final MetaGlobal uploadedMg = new MetaGlobal(null, null);
+
+ MockServer server = new MockServer() {
+ public void handle(Request request, Response response) {
+ if (request.getMethod().equals("PUT")) {
+ try {
+ ExtendedJSONObject body = new ExtendedJSONObject(request.getContent());
+ System.out.println(body.toJSONString());
+ assertTrue(body.containsKey("payload"));
+ assertFalse(body.containsKey("default"));
+
+ CryptoRecord rec = CryptoRecord.fromJSONRecord(body);
+ uploadedMg.setFromRecord(rec);
+ mgUploaded.set(true);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ this.handle(request, response, 200, "success");
+ return;
+ }
+ this.handle(request, response, 404, "missing");
+ }
+ };
+
+ data.startHTTPServer(server);
+ final MockMetaGlobalFetchDelegate delegate = doUpload(global);
+ data.stopHTTPServer();
+
+ assertTrue(delegate.successCalled);
+ assertTrue(mgUploaded.get());
+ assertEquals(TEST_SYNC_ID, uploadedMg.getSyncID());
+ assertEquals(TEST_STORAGE_VERSION, uploadedMg.getStorageVersion().longValue());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestResource.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestResource.java
new file mode 100644
index 000000000..e53d02d33
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestResource.java
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockResourceDelegate;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.HttpResponseObserver;
+
+import java.net.URISyntaxException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestResource {
+ private static final int TEST_PORT = HTTPServerTestHelper.getTestPort();
+ private static final String TEST_SERVER = "http://localhost:" + TEST_PORT;
+
+ private HTTPServerTestHelper data = new HTTPServerTestHelper();
+
+ @SuppressWarnings("static-method")
+ @Before
+ public void setUp() {
+ BaseResource.rewriteLocalhost = false;
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testLocalhostRewriting() throws URISyntaxException {
+ BaseResource r = new BaseResource("http://localhost:5000/foo/bar", true);
+ assertEquals("http://10.0.2.2:5000/foo/bar", r.getURI().toASCIIString());
+ }
+
+ @SuppressWarnings("static-method")
+ public MockResourceDelegate doGet() throws URISyntaxException {
+ final BaseResource r = new BaseResource(TEST_SERVER + "/foo/bar");
+ MockResourceDelegate delegate = new MockResourceDelegate();
+ r.delegate = delegate;
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ r.get();
+ }
+ });
+ return delegate;
+ }
+
+ @Test
+ public void testTrivialFetch() throws URISyntaxException {
+ MockServer server = data.startHTTPServer();
+ server.expectedBasicAuthHeader = MockResourceDelegate.EXPECT_BASIC;
+ MockResourceDelegate delegate = doGet();
+ assertTrue(delegate.handledHttpResponse);
+ data.stopHTTPServer();
+ }
+
+ public static class MockHttpResponseObserver implements HttpResponseObserver {
+ public HttpResponse response = null;
+
+ @Override
+ public void observeHttpResponse(HttpUriRequest request, HttpResponse response) {
+ this.response = response;
+ }
+ }
+
+ @Test
+ public void testObservers() throws URISyntaxException {
+ data.startHTTPServer();
+ // Check that null observer doesn't fail.
+ BaseResource.addHttpResponseObserver(null);
+ doGet(); // HTTP server stopped in callback.
+
+ // Check that multiple non-null observers gets called with reasonable HttpResponse.
+ MockHttpResponseObserver observers[] = { new MockHttpResponseObserver(), new MockHttpResponseObserver() };
+ for (MockHttpResponseObserver observer : observers) {
+ BaseResource.addHttpResponseObserver(observer);
+ assertTrue(BaseResource.isHttpResponseObserver(observer));
+ assertNull(observer.response);
+ }
+
+ doGet(); // HTTP server stopped in callback.
+
+ for (MockHttpResponseObserver observer : observers) {
+ assertNotNull(observer.response);
+ assertEquals(200, observer.response.getStatusLine().getStatusCode());
+ }
+
+ data.stopHTTPServer();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestRetryAfter.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestRetryAfter.java
new file mode 100644
index 000000000..429ad29d4
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestRetryAfter.java
@@ -0,0 +1,87 @@
+package org.mozilla.android.sync.net.test;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.impl.cookie.DateUtils;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+import ch.boye.httpclientandroidlib.message.BasicStatusLine;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.net.SyncResponse;
+
+import java.util.Date;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestRetryAfter {
+ private int TEST_SECONDS = 120;
+
+ @Test
+ public void testRetryAfterParsesSeconds() {
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+ response.addHeader("Retry-After", Long.toString(TEST_SECONDS)); // Retry-After given in seconds.
+
+ final SyncResponse syncResponse = new SyncResponse(response);
+ assertEquals(TEST_SECONDS, syncResponse.retryAfterInSeconds());
+ }
+
+ @Test
+ public void testRetryAfterParsesHTTPDate() {
+ Date future = new Date(System.currentTimeMillis() + TEST_SECONDS * 1000);
+
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+ response.addHeader("Retry-After", DateUtils.formatDate(future));
+
+ final SyncResponse syncResponse = new SyncResponse(response);
+ assertTrue(syncResponse.retryAfterInSeconds() > TEST_SECONDS - 15);
+ assertTrue(syncResponse.retryAfterInSeconds() < TEST_SECONDS + 15);
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testRetryAfterParsesMalformed() {
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+ response.addHeader("Retry-After", "10X");
+
+ final SyncResponse syncResponse = new SyncResponse(response);
+ assertEquals(-1, syncResponse.retryAfterInSeconds());
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testRetryAfterParsesNeither() {
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+
+ final SyncResponse syncResponse = new SyncResponse(response);
+ assertEquals(-1, syncResponse.retryAfterInSeconds());
+ }
+
+ @Test
+ public void testRetryAfterParsesLargerRetryAfter() {
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+ response.addHeader("Retry-After", Long.toString(TEST_SECONDS + 1));
+ response.addHeader("X-Weave-Backoff", Long.toString(TEST_SECONDS));
+
+ final SyncResponse syncResponse = new SyncResponse(response);
+ assertEquals(1000 * (TEST_SECONDS + 1), syncResponse.totalBackoffInMilliseconds());
+ }
+
+ @Test
+ public void testRetryAfterParsesLargerXWeaveBackoff() {
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+ response.addHeader("Retry-After", Long.toString(TEST_SECONDS));
+ response.addHeader("X-Weave-Backoff", Long.toString(TEST_SECONDS + 1));
+
+ final SyncResponse syncResponse = new SyncResponse(response);
+ assertEquals(1000 * (TEST_SECONDS + 1), syncResponse.totalBackoffInMilliseconds());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestServer11Repository.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestServer11Repository.java
new file mode 100644
index 000000000..3aa0a91ec
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestServer11Repository.java
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.InfoConfiguration;
+import org.mozilla.gecko.sync.repositories.Server11Repository;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+@RunWith(TestRunner.class)
+public class TestServer11Repository {
+
+ private static final String COLLECTION = "bookmarks";
+ private static final String COLLECTION_URL = "http://foo.com/1.1/n6ec3u5bee3tixzp2asys7bs6fve4jfw/storage";
+
+ protected final InfoCollections infoCollections = new InfoCollections();
+ protected final InfoConfiguration infoConfiguration = new InfoConfiguration();
+
+ public static void assertQueryEquals(String expected, URI u) {
+ Assert.assertEquals(expected, u.getRawQuery());
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testCollectionURIFull() throws URISyntaxException {
+ Server11Repository r = new Server11Repository(COLLECTION, COLLECTION_URL, null, infoCollections, infoConfiguration);
+ assertQueryEquals("full=1&newer=5000.000", r.collectionURI(true, 5000000L, -1, null, null, null));
+ assertQueryEquals("newer=1230.000", r.collectionURI(false, 1230000L, -1, null, null, null));
+ assertQueryEquals("newer=5000.000&limit=10", r.collectionURI(false, 5000000L, 10, null, null, null));
+ assertQueryEquals("full=1&newer=5000.000&sort=index", r.collectionURI(true, 5000000L, 0, "index", null, null));
+ assertQueryEquals("full=1&ids=123,abc", r.collectionURI(true, -1L, -1, null, "123,abc", null));
+ }
+
+ @Test
+ public void testCollectionURI() throws URISyntaxException {
+ Server11Repository noTrailingSlash = new Server11Repository(COLLECTION, COLLECTION_URL, null, infoCollections, infoConfiguration);
+ Server11Repository trailingSlash = new Server11Repository(COLLECTION, COLLECTION_URL + "/", null, infoCollections, infoConfiguration);
+ Assert.assertEquals("http://foo.com/1.1/n6ec3u5bee3tixzp2asys7bs6fve4jfw/storage/bookmarks", noTrailingSlash.collectionURI().toASCIIString());
+ Assert.assertEquals("http://foo.com/1.1/n6ec3u5bee3tixzp2asys7bs6fve4jfw/storage/bookmarks", trailingSlash.collectionURI().toASCIIString());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestSyncStorageRequest.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestSyncStorageRequest.java
new file mode 100644
index 000000000..0e6447c27
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestSyncStorageRequest.java
@@ -0,0 +1,269 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import org.json.simple.JSONObject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.BaseTestStorageRequestDelegate;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageRecordRequest;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+@RunWith(TestRunner.class)
+public class TestSyncStorageRequest {
+ private static final int TEST_PORT = HTTPServerTestHelper.getTestPort();
+ private static final String TEST_SERVER = "http://localhost:" + TEST_PORT;
+
+ private static final String LOCAL_META_URL = TEST_SERVER + "/1.1/c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd/storage/meta/global";
+ private static final String LOCAL_BAD_REQUEST_URL = TEST_SERVER + "/1.1/c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd/storage/bad";
+
+ private static final String EXPECTED_ERROR_CODE = "12";
+ private static final String EXPECTED_RETRY_AFTER_ERROR_MESSAGE = "{error:'informative error message'}";
+
+ // Corresponds to rnewman+testandroid@mozilla.com.
+ private static final String TEST_USERNAME = "c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd";
+ private static final String TEST_PASSWORD = "password";
+ private final AuthHeaderProvider authHeaderProvider = new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD);
+
+ private HTTPServerTestHelper data = new HTTPServerTestHelper();
+
+ public class TestSyncStorageRequestDelegate extends
+ BaseTestStorageRequestDelegate {
+ public TestSyncStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+ super(authHeaderProvider);
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse res) {
+ assertTrue(res.wasSuccessful());
+ assertTrue(res.httpResponse().containsHeader("X-Weave-Timestamp"));
+
+ // Make sure we consume the rest of the body, so we can reuse the
+ // connection. Even test code has to be correct in this regard!
+ try {
+ System.out.println("Success body: " + res.body());
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ BaseResource.consumeEntity(res);
+ data.stopHTTPServer();
+ }
+ }
+
+ public class TestBadSyncStorageRequestDelegate extends
+ BaseTestStorageRequestDelegate {
+
+ public TestBadSyncStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+ super(authHeaderProvider);
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse res) {
+ assertTrue(!res.wasSuccessful());
+ assertTrue(res.httpResponse().containsHeader("X-Weave-Timestamp"));
+ try {
+ String responseMessage = res.getErrorMessage();
+ String expectedMessage = SyncStorageResponse.SERVER_ERROR_MESSAGES.get(EXPECTED_ERROR_CODE);
+ assertEquals(expectedMessage, responseMessage);
+ } catch (Exception e) {
+ fail("Got exception fetching error message.");
+ }
+ BaseResource.consumeEntity(res);
+ data.stopHTTPServer();
+ }
+ }
+
+
+ @Test
+ public void testSyncStorageRequest() throws URISyntaxException, IOException {
+ BaseResource.rewriteLocalhost = false;
+ data.startHTTPServer();
+ SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_META_URL));
+ TestSyncStorageRequestDelegate delegate = new TestSyncStorageRequestDelegate(authHeaderProvider);
+ r.delegate = delegate;
+ r.get();
+ // Server is stopped in the callback.
+ }
+
+ public class ErrorMockServer extends MockServer {
+ @Override
+ public void handle(Request request, Response response) {
+ super.handle(request, response, 400, EXPECTED_ERROR_CODE);
+ }
+ }
+
+ @Test
+ public void testErrorResponse() throws URISyntaxException {
+ BaseResource.rewriteLocalhost = false;
+ data.startHTTPServer(new ErrorMockServer());
+ SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_BAD_REQUEST_URL));
+ TestBadSyncStorageRequestDelegate delegate = new TestBadSyncStorageRequestDelegate(authHeaderProvider);
+ r.delegate = delegate;
+ r.post(new JSONObject());
+ // Server is stopped in the callback.
+ }
+
+ // Test that the Retry-After header is correctly parsed and that handleRequestFailure
+ // is being called.
+ public class TestRetryAfterSyncStorageRequestDelegate extends BaseTestStorageRequestDelegate {
+
+ public TestRetryAfterSyncStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+ super(authHeaderProvider);
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse res) {
+ assertTrue(!res.wasSuccessful());
+ assertTrue(res.httpResponse().containsHeader("Retry-After"));
+ assertEquals(res.retryAfterInSeconds(), 3001);
+ try {
+ String responseMessage = res.getErrorMessage();
+ String expectedMessage = EXPECTED_RETRY_AFTER_ERROR_MESSAGE;
+ assertEquals(expectedMessage, responseMessage);
+ } catch (Exception e) {
+ fail("Got exception fetching error message.");
+ }
+ BaseResource.consumeEntity(res);
+ data.stopHTTPServer();
+ }
+ }
+
+ public class RetryAfterMockServer extends MockServer {
+ @Override
+ public void handle(Request request, Response response) {
+ String errorBody = EXPECTED_RETRY_AFTER_ERROR_MESSAGE;
+ response.setValue("Retry-After", "3001");
+ super.handle(request, response, 503, errorBody);
+ }
+ }
+
+ @Test
+ public void testRetryAfterResponse() throws URISyntaxException {
+ BaseResource.rewriteLocalhost = false;
+ data.startHTTPServer(new RetryAfterMockServer());
+ SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_BAD_REQUEST_URL)); // URL not used -- we 503 every response
+ TestRetryAfterSyncStorageRequestDelegate delegate = new TestRetryAfterSyncStorageRequestDelegate(authHeaderProvider);
+ r.delegate = delegate;
+ r.post(new JSONObject());
+ // Server is stopped in the callback.
+ }
+
+ // Test that the X-Weave-Backoff header is correctly parsed and that handleRequestSuccess
+ // is still being called.
+ public class TestWeaveBackoffSyncStorageRequestDelegate extends
+ TestSyncStorageRequestDelegate {
+
+ public TestWeaveBackoffSyncStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+ super(authHeaderProvider);
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse res) {
+ assertTrue(res.httpResponse().containsHeader("X-Weave-Backoff"));
+ assertEquals(res.weaveBackoffInSeconds(), 1801);
+ super.handleRequestSuccess(res);
+ }
+ }
+
+ public class WeaveBackoffMockServer extends MockServer {
+ @Override
+ public void handle(Request request, Response response) {
+ response.setValue("X-Weave-Backoff", "1801");
+ super.handle(request, response);
+ }
+ }
+
+ @Test
+ public void testWeaveBackoffResponse() throws URISyntaxException {
+ BaseResource.rewriteLocalhost = false;
+ data.startHTTPServer(new WeaveBackoffMockServer());
+ SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_META_URL)); // URL re-used -- we need any successful response
+ TestWeaveBackoffSyncStorageRequestDelegate delegate = new TestWeaveBackoffSyncStorageRequestDelegate(new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD));
+ r.delegate = delegate;
+ r.post(new JSONObject());
+ // Server is stopped in the callback.
+ }
+
+ // Test that the X-Weave-{Quota-Remaining, Alert, Records} headers are correctly parsed and
+ // that handleRequestSuccess is still being called.
+ public class TestHeadersSyncStorageRequestDelegate extends
+ TestSyncStorageRequestDelegate {
+
+ public TestHeadersSyncStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+ super(authHeaderProvider);
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse res) {
+ assertTrue(res.httpResponse().containsHeader("X-Weave-Quota-Remaining"));
+ assertTrue(res.httpResponse().containsHeader("X-Weave-Alert"));
+ assertTrue(res.httpResponse().containsHeader("X-Weave-Records"));
+ assertEquals(65536, res.weaveQuotaRemaining());
+ assertEquals("First weave alert string", res.weaveAlert());
+ assertEquals(50, res.weaveRecords());
+
+ super.handleRequestSuccess(res);
+ }
+ }
+
+ public class HeadersMockServer extends MockServer {
+ @Override
+ public void handle(Request request, Response response) {
+ response.setValue("X-Weave-Quota-Remaining", "65536");
+ response.setValue("X-Weave-Alert", "First weave alert string");
+ response.addValue("X-Weave-Alert", "Second weave alert string");
+ response.setValue("X-Weave-Records", "50");
+
+ super.handle(request, response);
+ }
+ }
+
+ @Test
+ public void testHeadersResponse() throws URISyntaxException {
+ BaseResource.rewriteLocalhost = false;
+ data.startHTTPServer(new HeadersMockServer());
+ SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_META_URL)); // URL re-used -- we need any successful response
+ TestHeadersSyncStorageRequestDelegate delegate = new TestHeadersSyncStorageRequestDelegate(authHeaderProvider);
+ r.delegate = delegate;
+ r.post(new JSONObject());
+ // Server is stopped in the callback.
+ }
+
+ public class DeleteMockServer extends MockServer {
+ @Override
+ public void handle(Request request, Response response) {
+ assertNotNull(request.getValue("x-confirm-delete"));
+ assertEquals("1", request.getValue("x-confirm-delete"));
+ super.handle(request, response);
+ }
+ }
+
+ @Test
+ public void testDelete() throws URISyntaxException {
+ BaseResource.rewriteLocalhost = false;
+ data.startHTTPServer(new DeleteMockServer());
+ SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_META_URL)); // URL re-used -- we need any successful response
+ TestSyncStorageRequestDelegate delegate = new TestSyncStorageRequestDelegate(authHeaderProvider);
+ r.delegate = delegate;
+ r.delete();
+ // Server is stopped in the callback.
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/SynchronizerHelpers.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/SynchronizerHelpers.java
new file mode 100644
index 000000000..f67f7e334
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/SynchronizerHelpers.java
@@ -0,0 +1,282 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import android.content.Context;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.WBORepository;
+import org.mozilla.gecko.sync.repositories.FetchFailedException;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
+import org.mozilla.gecko.sync.repositories.NoStoreDelegateException;
+import org.mozilla.gecko.sync.repositories.StoreFailedException;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+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.domain.Record;
+
+import java.util.ArrayList;
+import java.util.concurrent.ExecutorService;
+
+public class SynchronizerHelpers {
+ public static final String FAIL_SENTINEL = "Fail";
+
+ /**
+ * Store one at a time, failing if the guid contains FAIL_SENTINEL.
+ */
+ public static class FailFetchWBORepository extends WBORepository {
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ delegate.deferredCreationDelegate().onSessionCreated(new WBORepositorySession(this) {
+ @Override
+ public void fetchSince(long timestamp,
+ final RepositorySessionFetchRecordsDelegate delegate) {
+ super.fetchSince(timestamp, new RepositorySessionFetchRecordsDelegate() {
+ @Override
+ public void onFetchedRecord(Record record) {
+ if (record.guid.contains(FAIL_SENTINEL)) {
+ delegate.onFetchFailed(new FetchFailedException(), record);
+ } else {
+ delegate.onFetchedRecord(record);
+ }
+ }
+
+ @Override
+ public void onFetchFailed(Exception ex, Record record) {
+ delegate.onFetchFailed(ex, record);
+ }
+
+ @Override
+ public void onFetchCompleted(long fetchEnd) {
+ delegate.onFetchCompleted(fetchEnd);
+ }
+
+ @Override
+ public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor) {
+ return this;
+ }
+ });
+ }
+ });
+ }
+ }
+
+ /**
+ * Store one at a time, failing if the guid contains FAIL_SENTINEL.
+ */
+ public static class SerialFailStoreWBORepository extends WBORepository {
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ delegate.deferredCreationDelegate().onSessionCreated(new WBORepositorySession(this) {
+ @Override
+ public void store(final Record record) throws NoStoreDelegateException {
+ if (delegate == null) {
+ throw new NoStoreDelegateException();
+ }
+ if (record.guid.contains(FAIL_SENTINEL)) {
+ delegate.onRecordStoreFailed(new StoreFailedException(), record.guid);
+ } else {
+ super.store(record);
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * Store in batches, failing if any of the batch guids contains "Fail".
+ * <p>
+ * This will drop the final batch.
+ */
+ public static class BatchFailStoreWBORepository extends WBORepository {
+ public final int batchSize;
+ public ArrayList<Record> batch = new ArrayList<Record>();
+ public boolean batchShouldFail = false;
+
+ public class BatchFailStoreWBORepositorySession extends WBORepositorySession {
+ public BatchFailStoreWBORepositorySession(WBORepository repository) {
+ super(repository);
+ }
+
+ public void superStore(final Record record) throws NoStoreDelegateException {
+ super.store(record);
+ }
+
+ @Override
+ public void store(final Record record) throws NoStoreDelegateException {
+ if (delegate == null) {
+ throw new NoStoreDelegateException();
+ }
+ synchronized (batch) {
+ batch.add(record);
+ if (record.guid.contains("Fail")) {
+ batchShouldFail = true;
+ }
+
+ if (batch.size() >= batchSize) {
+ flush();
+ }
+ }
+ }
+
+ public void flush() {
+ final ArrayList<Record> thisBatch = new ArrayList<Record>(batch);
+ final boolean thisBatchShouldFail = batchShouldFail;
+ batchShouldFail = false;
+ batch.clear();
+ storeWorkQueue.execute(new Runnable() {
+ @Override
+ public void run() {
+ Logger.trace("XXX", "Notifying about batch. Failure? " + thisBatchShouldFail);
+ for (Record batchRecord : thisBatch) {
+ if (thisBatchShouldFail) {
+ delegate.onRecordStoreFailed(new StoreFailedException(), batchRecord.guid);
+ } else {
+ try {
+ superStore(batchRecord);
+ } catch (NoStoreDelegateException e) {
+ delegate.onRecordStoreFailed(e, batchRecord.guid);
+ }
+ }
+ }
+ }
+ });
+ }
+
+ @Override
+ public void storeDone() {
+ synchronized (batch) {
+ flush();
+ // Do this in a Runnable so that the timestamp is grabbed after any upload.
+ final Runnable r = new Runnable() {
+ @Override
+ public void run() {
+ synchronized (batch) {
+ Logger.trace("XXX", "Calling storeDone.");
+ storeDone(now());
+ }
+ }
+ };
+ storeWorkQueue.execute(r);
+ }
+ }
+ }
+ public BatchFailStoreWBORepository(int batchSize) {
+ super();
+ this.batchSize = batchSize;
+ }
+
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ delegate.deferredCreationDelegate().onSessionCreated(new BatchFailStoreWBORepositorySession(this));
+ }
+ }
+
+ public static class TrackingWBORepository extends WBORepository {
+ @Override
+ public synchronized boolean shouldTrack() {
+ return true;
+ }
+ }
+
+ public static class BeginFailedException extends Exception {
+ private static final long serialVersionUID = -2349459755976915096L;
+ }
+
+ public static class FinishFailedException extends Exception {
+ private static final long serialVersionUID = -4644528423867070934L;
+ }
+
+ public static class BeginErrorWBORepository extends TrackingWBORepository {
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ delegate.deferredCreationDelegate().onSessionCreated(new BeginErrorWBORepositorySession(this));
+ }
+
+ public class BeginErrorWBORepositorySession extends WBORepositorySession {
+ public BeginErrorWBORepositorySession(WBORepository repository) {
+ super(repository);
+ }
+
+ @Override
+ public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
+ delegate.onBeginFailed(new BeginFailedException());
+ }
+ }
+ }
+
+ public static class FinishErrorWBORepository extends TrackingWBORepository {
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ delegate.deferredCreationDelegate().onSessionCreated(new FinishErrorWBORepositorySession(this));
+ }
+
+ public class FinishErrorWBORepositorySession extends WBORepositorySession {
+ public FinishErrorWBORepositorySession(WBORepository repository) {
+ super(repository);
+ }
+
+ @Override
+ public void finish(final RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
+ delegate.onFinishFailed(new FinishFailedException());
+ }
+ }
+ }
+
+ public static class DataAvailableWBORepository extends TrackingWBORepository {
+ public boolean dataAvailable = true;
+
+ public DataAvailableWBORepository(boolean dataAvailable) {
+ this.dataAvailable = dataAvailable;
+ }
+
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ delegate.deferredCreationDelegate().onSessionCreated(new DataAvailableWBORepositorySession(this));
+ }
+
+ public class DataAvailableWBORepositorySession extends WBORepositorySession {
+ public DataAvailableWBORepositorySession(WBORepository repository) {
+ super(repository);
+ }
+
+ @Override
+ public boolean dataAvailable() {
+ return dataAvailable;
+ }
+ }
+ }
+
+ public static class ShouldSkipWBORepository extends TrackingWBORepository {
+ public boolean shouldSkip = true;
+
+ public ShouldSkipWBORepository(boolean shouldSkip) {
+ this.shouldSkip = shouldSkip;
+ }
+
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ delegate.deferredCreationDelegate().onSessionCreated(new ShouldSkipWBORepositorySession(this));
+ }
+
+ public class ShouldSkipWBORepositorySession extends WBORepositorySession {
+ public ShouldSkipWBORepositorySession(WBORepository repository) {
+ super(repository);
+ }
+
+ @Override
+ public boolean shouldSkip() {
+ return shouldSkip;
+ }
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCollectionKeys.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCollectionKeys.java
new file mode 100644
index 000000000..76791a6ed
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCollectionKeys.java
@@ -0,0 +1,197 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import org.json.simple.JSONArray;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CollectionKeys;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.NoCollectionKeysSetException;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Set;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+@RunWith(TestRunner.class)
+public class TestCollectionKeys {
+
+ @Test
+ public void testDefaultKeys() throws CryptoException, NoCollectionKeysSetException {
+ CollectionKeys ck = new CollectionKeys();
+ try {
+ ck.defaultKeyBundle();
+ fail("defaultKeys should throw.");
+ } catch (NoCollectionKeysSetException ex) {
+ // Good.
+ }
+ KeyBundle testKeys = KeyBundle.withRandomKeys();
+ ck.setDefaultKeyBundle(testKeys);
+ assertEquals(testKeys, ck.defaultKeyBundle());
+ }
+
+ @Test
+ public void testKeyForCollection() throws CryptoException, NoCollectionKeysSetException {
+ CollectionKeys ck = new CollectionKeys();
+ try {
+ ck.keyBundleForCollection("test");
+ fail("keyForCollection should throw.");
+ } catch (NoCollectionKeysSetException ex) {
+ // Good.
+ }
+ KeyBundle testKeys = KeyBundle.withRandomKeys();
+ KeyBundle otherKeys = KeyBundle.withRandomKeys();
+
+ ck.setDefaultKeyBundle(testKeys);
+ assertEquals(testKeys, ck.defaultKeyBundle());
+ assertEquals(testKeys, ck.keyBundleForCollection("test")); // Returns default.
+
+ ck.setKeyBundleForCollection("test", otherKeys);
+ assertEquals(otherKeys, ck.keyBundleForCollection("test")); // Returns default.
+
+ }
+
+ public static void assertSame(byte[] arrayOne, byte[] arrayTwo) {
+ assertTrue(Arrays.equals(arrayOne, arrayTwo));
+ }
+
+
+ @Test
+ public void testSetKeysFromWBO() throws IOException, NonObjectJSONException, CryptoException, NoCollectionKeysSetException {
+ String json = "{\"default\":[\"3fI6k1exImMgAKjilmMaAWxGqEIzFX/9K5EjEgH99vc=\",\"/AMaoCX4hzic28WY94XtokNi7N4T0nv+moS1y5wlbug=\"],\"collections\":{},\"collection\":\"crypto\",\"id\":\"keys\"}";
+ CryptoRecord rec = new CryptoRecord(json);
+
+ KeyBundle syncKeyBundle = new KeyBundle("slyjcrjednxd6rf4cr63vqilmkus6zbe", "6m8mv8ex2brqnrmsb9fjuvfg7y");
+ rec.keyBundle = syncKeyBundle;
+
+ rec.encrypt();
+ CollectionKeys ck = new CollectionKeys();
+ ck.setKeyPairsFromWBO(rec, syncKeyBundle);
+ byte[] input = "3fI6k1exImMgAKjilmMaAWxGqEIzFX/9K5EjEgH99vc=".getBytes("UTF-8");
+ byte[] expected = Base64.decodeBase64(input);
+ assertSame(expected, ck.defaultKeyBundle().getEncryptionKey());
+ }
+
+ @Test
+ public void testCryptoRecordFromCollectionKeys() throws CryptoException, NoCollectionKeysSetException, IOException, NonObjectJSONException {
+ CollectionKeys ck1 = CollectionKeys.generateCollectionKeys();
+ assertNotNull(ck1.defaultKeyBundle());
+ assertEquals(ck1.keyBundleForCollection("foobar"), ck1.defaultKeyBundle());
+ CryptoRecord rec = ck1.asCryptoRecord();
+ assertEquals(rec.collection, "crypto");
+ assertEquals(rec.guid, "keys");
+ JSONArray defaultKey = (JSONArray) rec.payload.get("default");
+
+ assertSame(Base64.decodeBase64((String) (defaultKey.get(0))), ck1.defaultKeyBundle().getEncryptionKey());
+ CollectionKeys ck2 = new CollectionKeys();
+ ck2.setKeyPairsFromWBO(rec, null);
+ assertSame(ck1.defaultKeyBundle().getEncryptionKey(), ck2.defaultKeyBundle().getEncryptionKey());
+ }
+
+ @Test
+ public void testCreateKeysBundle() throws CryptoException, NonObjectJSONException, IOException, NoCollectionKeysSetException {
+ String username = "b6evr62dptbxz7fvebek7btljyu322wp";
+ String friendlyBase32SyncKey = "basuxv2426eqj7frhvpcwkavdi";
+
+ KeyBundle syncKeyBundle = new KeyBundle(username, friendlyBase32SyncKey);
+
+ CollectionKeys ck = CollectionKeys.generateCollectionKeys();
+ CryptoRecord unencrypted = ck.asCryptoRecord();
+ unencrypted.keyBundle = syncKeyBundle;
+ CryptoRecord encrypted = unencrypted.encrypt();
+
+ CollectionKeys ckDecrypted = new CollectionKeys();
+ ckDecrypted.setKeyPairsFromWBO(encrypted, syncKeyBundle);
+
+ // Compare decrypted keys to the keys that were set upon creation
+ assertArrayEquals(ck.defaultKeyBundle().getEncryptionKey(), ckDecrypted.defaultKeyBundle().getEncryptionKey());
+ assertArrayEquals(ck.defaultKeyBundle().getHMACKey(), ckDecrypted.defaultKeyBundle().getHMACKey());
+ }
+
+ @Test
+ public void testDifferences() throws Exception {
+ KeyBundle kb1 = KeyBundle.withRandomKeys();
+ KeyBundle kb2 = KeyBundle.withRandomKeys();
+ KeyBundle kb3 = KeyBundle.withRandomKeys();
+ CollectionKeys a = CollectionKeys.generateCollectionKeys();
+ CollectionKeys b = CollectionKeys.generateCollectionKeys();
+ Set<String> diffs;
+
+ a.setKeyBundleForCollection("1", kb1);
+ b.setKeyBundleForCollection("1", kb1);
+ diffs = CollectionKeys.differences(a, b);
+ assertTrue(diffs.isEmpty());
+
+ a.setKeyBundleForCollection("2", kb2);
+ diffs = CollectionKeys.differences(a, b);
+ assertArrayEquals(new String[] { "2" }, diffs.toArray(new String[diffs.size()]));
+
+ b.setKeyBundleForCollection("3", kb3);
+ diffs = CollectionKeys.differences(a, b);
+ assertEquals(2, diffs.size());
+ assertTrue(diffs.contains("2"));
+ assertTrue(diffs.contains("3"));
+
+ b.setKeyBundleForCollection("1", KeyBundle.withRandomKeys());
+ diffs = CollectionKeys.differences(a, b);
+ assertEquals(3, diffs.size());
+
+ // This tests that explicitly setting a default key works.
+ a = CollectionKeys.generateCollectionKeys();
+ b = CollectionKeys.generateCollectionKeys();
+ b.setDefaultKeyBundle(a.defaultKeyBundle());
+ a.setKeyBundleForCollection("a", a.defaultKeyBundle());
+ b.setKeyBundleForCollection("b", b.defaultKeyBundle());
+ assertTrue(CollectionKeys.differences(a, b).isEmpty());
+ assertTrue(CollectionKeys.differences(b, a).isEmpty());
+ }
+
+ @Test
+ public void testEquals() throws Exception {
+ KeyBundle kb1 = KeyBundle.withRandomKeys();
+ KeyBundle kb2 = KeyBundle.withRandomKeys();
+ CollectionKeys a = CollectionKeys.generateCollectionKeys();
+ CollectionKeys b = CollectionKeys.generateCollectionKeys();
+
+ // Random keys are different.
+ assertFalse(a.equals(b));
+ assertFalse(b.equals(a));
+
+ // keys with unset default key bundles are different.
+ b.setDefaultKeyBundle(null);
+ assertFalse(a.equals(b));
+
+ // keys with equal default key bundles and no other collections are the same.
+ b.setDefaultKeyBundle(a.defaultKeyBundle());
+ assertTrue(a.equals(b));
+
+ // keys with equal defaults and equal collections are the same.
+ a.setKeyBundleForCollection("1", kb1);
+ b.setKeyBundleForCollection("1", kb1);
+ assertTrue(a.equals(b));
+
+ // keys with equal defaults but some collection missing are different.
+ a.setKeyBundleForCollection("2", kb2);
+ assertFalse(a.equals(b));
+ assertFalse(b.equals(a));
+
+ // keys with equal defaults and some collection set to the default are the same.
+ a.setKeyBundleForCollection("2", a.defaultKeyBundle());
+ b.setKeyBundleForCollection("3", b.defaultKeyBundle());
+ assertTrue(a.equals(b));
+ assertTrue(b.equals(a));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCommandProcessor.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCommandProcessor.java
new file mode 100644
index 000000000..adab2d738
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCommandProcessor.java
@@ -0,0 +1,117 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CommandProcessor;
+import org.mozilla.gecko.sync.CommandRunner;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestCommandProcessor extends CommandProcessor {
+
+ public static final String commandType = "displayURI";
+ public static final String commandWithNoArgs = "{\"command\":\"displayURI\"}";
+ public static final String commandWithNoType = "{\"args\":[\"https://bugzilla.mozilla.org/show_bug.cgi?id=731341\",\"PKsljsuqYbGg\"]}";
+ public static final String wellFormedCommand = "{\"args\":[\"https://bugzilla.mozilla.org/show_bug.cgi?id=731341\",\"PKsljsuqYbGg\"],\"command\":\"displayURI\"}";
+ public static final String wellFormedCommandWithNullArgs = "{\"args\":[\"https://bugzilla.mozilla.org/show_bug.cgi?id=731341\",null,\"PKsljsuqYbGg\",null],\"command\":\"displayURI\"}";
+
+ private boolean commandExecuted;
+
+ // Session is not used in these tests.
+ protected final GlobalSession session = null;
+
+ public class MockCommandRunner extends CommandRunner {
+ public MockCommandRunner(int argCount) {
+ super(argCount);
+ }
+
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ commandExecuted = true;
+ }
+ }
+
+ @Test
+ public void testRegisterCommand() throws NonObjectJSONException, IOException {
+ assertNull(commands.get(commandType));
+ this.registerCommand(commandType, new MockCommandRunner(1));
+ assertNotNull(commands.get(commandType));
+ }
+
+ @Test
+ public void testProcessRegisteredCommand() throws NonObjectJSONException, IOException {
+ commandExecuted = false;
+ ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(wellFormedCommand);
+ this.registerCommand(commandType, new MockCommandRunner(1));
+ this.processCommand(session, unparsedCommand);
+ assertTrue(commandExecuted);
+ }
+
+ @Test
+ public void testProcessUnregisteredCommand() throws NonObjectJSONException, IOException {
+ commandExecuted = false;
+ ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(wellFormedCommand);
+ this.processCommand(session, unparsedCommand);
+ assertFalse(commandExecuted);
+ }
+
+ @Test
+ public void testProcessInvalidCommand() throws NonObjectJSONException, IOException {
+ ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(commandWithNoType);
+ this.registerCommand(commandType, new MockCommandRunner(1));
+ this.processCommand(session, unparsedCommand);
+ assertFalse(commandExecuted);
+ }
+
+ @Test
+ public void testParseCommandNoType() throws NonObjectJSONException, IOException {
+ ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(commandWithNoType);
+ assertNull(CommandProcessor.parseCommand(unparsedCommand));
+ }
+
+ @Test
+ public void testParseCommandNoArgs() throws NonObjectJSONException, IOException {
+ ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(commandWithNoArgs);
+ assertNull(CommandProcessor.parseCommand(unparsedCommand));
+ }
+
+ @Test
+ public void testParseWellFormedCommand() throws NonObjectJSONException, IOException {
+ ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(wellFormedCommand);
+ Command parsedCommand = CommandProcessor.parseCommand(unparsedCommand);
+ assertNotNull(parsedCommand);
+ assertEquals(2, parsedCommand.args.size());
+ assertEquals(commandType, parsedCommand.commandType);
+ }
+
+ @Test
+ public void testParseCommandNullArg() throws NonObjectJSONException, IOException {
+ ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(wellFormedCommandWithNullArgs);
+ Command parsedCommand = CommandProcessor.parseCommand(unparsedCommand);
+ assertNotNull(parsedCommand);
+ assertEquals(4, parsedCommand.args.size());
+ assertEquals(commandType, parsedCommand.commandType);
+ final List<String> expectedArgs = new ArrayList<String>();
+ expectedArgs.add("https://bugzilla.mozilla.org/show_bug.cgi?id=731341");
+ expectedArgs.add(null);
+ expectedArgs.add("PKsljsuqYbGg");
+ expectedArgs.add(null);
+ assertEquals(expectedArgs, parsedCommand.getArgsList());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCryptoRecord.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCryptoRecord.java
new file mode 100644
index 000000000..a6b91eaf8
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCryptoRecord.java
@@ -0,0 +1,302 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+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.repositories.domain.ClientRecord;
+import org.mozilla.gecko.sync.repositories.domain.HistoryRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestCryptoRecord {
+ String base64EncryptionKey = "9K/wLdXdw+nrTtXo4ZpECyHFNr4d7aYHqeg3KW9+m6Q=";
+ String base64HmacKey = "MMntEfutgLTc8FlTLQFms8/xMPmCldqPlq/QQXEjx70=";
+
+ @Test
+ public void testBaseCryptoRecordEncrypt() throws IOException, NonObjectJSONException, CryptoException {
+
+ ExtendedJSONObject clearPayload = new ExtendedJSONObject("{\"id\":\"5qRsgXWRJZXr\"," +
+ "\"title\":\"Index of file:///Users/jason/Library/Application " +
+ "Support/Firefox/Profiles/ksgd7wpk.LocalSyncServer/weave/logs/\"," +
+ "\"histUri\":\"file:///Users/jason/Library/Application%20Support/Firefox/Profiles" +
+ "/ksgd7wpk.LocalSyncServer/weave/logs/\",\"visits\":[{\"type\":1," +
+ "\"date\":1319149012372425}]}");
+
+ CryptoRecord record = new CryptoRecord();
+ record.payload = clearPayload;
+ String expectedGUID = "5qRsgXWRJZXr";
+ record.guid = expectedGUID;
+ record.keyBundle = KeyBundle.fromBase64EncodedKeys(base64EncryptionKey, base64HmacKey);
+ record.encrypt();
+ assertTrue(record.payload.get("title") == null);
+ assertTrue(record.payload.get("ciphertext") != null);
+ assertEquals(expectedGUID, record.guid);
+ assertEquals(expectedGUID, record.toJSONObject().get("id"));
+ record.decrypt();
+ assertEquals(expectedGUID, record.toJSONObject().get("id"));
+ }
+
+ @Test
+ public void testEntireRecord() throws Exception {
+ // Check a raw JSON blob from a real Sync account.
+ String inputString = "{\"sortindex\": 131, \"payload\": \"{\\\"ciphertext\\\":\\\"YJB4dr0vZEIWPirfU2FCJvfzeSLiOP5QWasol2R6ILUxdHsJWuUuvTZVhxYQfTVNou6hVV67jfAvi5Cs+bqhhQsv7icZTiZhPTiTdVGt+uuMotxauVA5OryNGVEZgCCTvT3upzhDFdDbJzVd9O3/gU/b7r/CmAHykX8bTlthlbWeZ8oz6gwHJB5tPRU15nM/m/qW1vyKIw5pw/ZwtAy630AieRehGIGDk+33PWqsfyuT4EUFY9/Ly+8JlnqzxfiBCunIfuXGdLuqTjJOxgrK8mI4wccRFEdFEnmHvh5x7fjl1ID52qumFNQl8zkB75C8XK25alXqwvRR6/AQSP+BgQ==\\\",\\\"IV\\\":\\\"v/0BFgicqYQsd70T39rraA==\\\",\\\"hmac\\\":\\\"59605ed696f6e0e6e062a03510cff742bf6b50d695c042e8372a93f4c2d37dac\\\"}\", \"id\": \"0-P9fabp9vJD\", \"modified\": 1326254123.65}";
+ CryptoRecord record = CryptoRecord.fromJSONRecord(inputString);
+ assertEquals("0-P9fabp9vJD", record.guid);
+ assertEquals(1326254123650L, record.lastModified);
+ assertEquals(131, record.sortIndex);
+
+ String b64E = "0A7mU5SZ/tu7ZqwXW1og4qHVHN+zgEi4Xwfwjw+vEJw=";
+ String b64H = "11GN34O9QWXkjR06g8t0gWE1sGgQeWL0qxxWwl8Dmxs=";
+ record.keyBundle = KeyBundle.fromBase64EncodedKeys(b64E, b64H);
+ record.decrypt();
+
+ assertEquals("0-P9fabp9vJD", record.guid);
+ assertEquals(1326254123650L, record.lastModified);
+ assertEquals(131, record.sortIndex);
+
+ assertEquals("Customize Firefox", record.payload.get("title"));
+ assertEquals("0-P9fabp9vJD", record.payload.get("id"));
+ assertTrue(record.payload.get("tags") instanceof JSONArray);
+ }
+
+ @Test
+ public void testBaseCryptoRecordDecrypt() throws Exception {
+ String base64CipherText =
+ "NMsdnRulLwQsVcwxKW9XwaUe7ouJk5Wn"
+ + "80QhbD80l0HEcZGCynh45qIbeYBik0lg"
+ + "cHbKmlIxTJNwU+OeqipN+/j7MqhjKOGI"
+ + "lvbpiPQQLC6/ffF2vbzL0nzMUuSyvaQz"
+ + "yGGkSYM2xUFt06aNivoQTvU2GgGmUK6M"
+ + "vadoY38hhW2LCMkoZcNfgCqJ26lO1O0s"
+ + "EO6zHsk3IVz6vsKiJ2Hq6VCo7hu123wN"
+ + "egmujHWQSGyf8JeudZjKzfi0OFRRvvm4"
+ + "QAKyBWf0MgrW1F8SFDnVfkq8amCB7Nhd"
+ + "whgLWbN+21NitNwWYknoEWe1m6hmGZDg"
+ + "DT32uxzWxCV8QqqrpH/ZggViEr9uMgoy"
+ + "4lYaWqP7G5WKvvechc62aqnsNEYhH26A"
+ + "5QgzmlNyvB+KPFvPsYzxDnSCjOoRSLx7"
+ + "GG86wT59QZw=";
+ String base64IV = "GX8L37AAb2FZJMzIoXlX8w==";
+ String base16Hmac =
+ "b1e6c18ac30deb70236bc0d65a46f7a4"
+ + "dce3b8b0e02cf92182b914e3afa5eebc";
+
+ ExtendedJSONObject body = new ExtendedJSONObject();
+ ExtendedJSONObject payload = new ExtendedJSONObject();
+ payload.put("ciphertext", base64CipherText);
+ payload.put("IV", base64IV);
+ payload.put("hmac", base16Hmac);
+ body.put("payload", payload.toJSONString());
+ CryptoRecord record = CryptoRecord.fromJSONRecord(body);
+ byte[] decodedKey = Base64.decodeBase64(base64EncryptionKey.getBytes("UTF-8"));
+ byte[] decodedHMAC = Base64.decodeBase64(base64HmacKey.getBytes("UTF-8"));
+ record.keyBundle = new KeyBundle(decodedKey, decodedHMAC);
+
+ record.decrypt();
+ String id = (String) record.payload.get("id");
+ assertTrue(id.equals("5qRsgXWRJZXr"));
+ }
+
+ @Test
+ public void testBaseCryptoRecordSyncKeyBundle() throws UnsupportedEncodingException, CryptoException {
+ // These values pulled straight out of Firefox.
+ String key = "6m8mv8ex2brqnrmsb9fjuvfg7y";
+ String user = "c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd";
+
+ // Check our friendly base32 decoding.
+ assertTrue(Arrays.equals(Utils.decodeFriendlyBase32(key), Base64.decodeBase64("8xbKrJfQYwbFkguKmlSm/g==".getBytes("UTF-8"))));
+ KeyBundle bundle = new KeyBundle(user, key);
+ String expectedEncryptKeyBase64 = "/8RzbFT396htpZu5rwgIg2WKfyARgm7dLzsF5pwrVz8=";
+ String expectedHMACKeyBase64 = "NChGjrqoXYyw8vIYP2334cvmMtsjAMUZNqFwV2LGNkM=";
+ byte[] computedEncryptKey = bundle.getEncryptionKey();
+ byte[] computedHMACKey = bundle.getHMACKey();
+ assertTrue(Arrays.equals(computedEncryptKey, Base64.decodeBase64(expectedEncryptKeyBase64.getBytes("UTF-8"))));
+ assertTrue(Arrays.equals(computedHMACKey, Base64.decodeBase64(expectedHMACKeyBase64.getBytes("UTF-8"))));
+ }
+
+ @Test
+ public void testDecrypt() throws Exception {
+ String jsonInput = "{\"sortindex\": 90, \"payload\":" +
+ "\"{\\\"ciphertext\\\":\\\"F4ukf0" +
+ "LM+vhffiKyjaANXeUhfmOPPmQYX1XBoG" +
+ "Rh1LiHeKHB5rqjhzd7yAoxqgmFnkIgQF" +
+ "YPSqRAoCxWiAeGULTX+KM4MU5drbNyR/" +
+ "690JBWSyE1vQSiMGwNIbTKnOLGHKkQVY" +
+ "HDpajg5BNFfvHNQ5Jx7uM9uJcmuEjCI6" +
+ "GRMDKyKjhsTqCd99MONkY5rISutaWQ0e" +
+ "EXFgpA9RZPv4jgWlQhe+YrVnpcrTi20b" +
+ "NgKp3IfIeqEelrZ5FJd2WGZOA021d3e7" +
+ "P3Z4qptefH4Q9/hySrWsELWngBaydyn/" +
+ "IjsheZuKra3kJSST/4SvRZ7qXn\\\",\\" +
+ "\"IV\\\":\\\"GadPajeXhpk75K2YH+L" +
+ "y4w==\\\",\\\"hmac\\\":\\\"71442" +
+ "d946502e3ca475c70a633d3d37f4b4e9" +
+ "313a6d1041d0c0550cd354e7605\\\"}" +
+ "\", \"id\": \"hkZYpC-BH4Xi\", \"" +
+ "modified\": 1320183464.21}";
+ String base64EncryptionKey = "K8fV6PHG8RgugfHexGesbzTeOs2o12cr" +
+ "N/G3bz0Bx1M=";
+ String base64HmacKey = "nbceuI6w1RJbBzh+iCJHEs8p4lElsOma" +
+ "yUhx+OztVgM=";
+ String expectedDecryptedText = "{\"id\":\"hkZYpC-BH4Xi\",\"histU" +
+ "ri\":\"http://hathology.com/2008" +
+ "/06/how-to-edit-your-path-enviro" +
+ "nment-variables-on-mac-os-x/\",\"" +
+ "title\":\"How To Edit Your PATH " +
+ "Environment Variables On Mac OS " +
+ "X\",\"visits\":[{\"date\":131898" +
+ "2074310889,\"type\":1}]}";
+
+ KeyBundle keyBundle = KeyBundle.fromBase64EncodedKeys(base64EncryptionKey, base64HmacKey);
+
+ CryptoRecord encrypted = CryptoRecord.fromJSONRecord(jsonInput);
+ encrypted.keyBundle = keyBundle;
+ CryptoRecord decrypted = encrypted.decrypt();
+
+ // We don't necessarily produce exactly the same JSON but we do have the same values.
+ ExtendedJSONObject expectedJson = new ExtendedJSONObject(expectedDecryptedText);
+ assertEquals(expectedJson.get("id"), decrypted.payload.get("id"));
+ assertEquals(expectedJson.get("title"), decrypted.payload.get("title"));
+ assertEquals(expectedJson.get("histUri"), decrypted.payload.get("histUri"));
+ }
+
+ @Test
+ public void testEncryptDecrypt() throws Exception {
+ String originalText = "{\"id\":\"hkZYpC-BH4Xi\",\"histU" +
+ "ri\":\"http://hathology.com/2008" +
+ "/06/how-to-edit-your-path-enviro" +
+ "nment-variables-on-mac-os-x/\",\"" +
+ "title\":\"How To Edit Your PATH " +
+ "Environment Variables On Mac OS " +
+ "X\",\"visits\":[{\"date\":131898" +
+ "2074310889,\"type\":1}]}";
+ String base64EncryptionKey = "K8fV6PHG8RgugfHexGesbzTeOs2o12cr" +
+ "N/G3bz0Bx1M=";
+ String base64HmacKey = "nbceuI6w1RJbBzh+iCJHEs8p4lElsOma" +
+ "yUhx+OztVgM=";
+
+ KeyBundle keyBundle = KeyBundle.fromBase64EncodedKeys(base64EncryptionKey, base64HmacKey);
+
+ // Encrypt.
+ CryptoRecord unencrypted = new CryptoRecord(originalText);
+ unencrypted.keyBundle = keyBundle;
+ CryptoRecord encrypted = unencrypted.encrypt();
+
+ // Decrypt after round-trip through JSON.
+ CryptoRecord undecrypted = CryptoRecord.fromJSONRecord(encrypted.toJSONString());
+ undecrypted.keyBundle = keyBundle;
+ CryptoRecord decrypted = undecrypted.decrypt();
+
+ // We don't necessarily produce exactly the same JSON but we do have the same values.
+ ExtendedJSONObject expectedJson = new ExtendedJSONObject(originalText);
+ assertEquals(expectedJson.get("id"), decrypted.payload.get("id"));
+ assertEquals(expectedJson.get("title"), decrypted.payload.get("title"));
+ assertEquals(expectedJson.get("histUri"), decrypted.payload.get("histUri"));
+ }
+
+ @Test
+ public void testDecryptKeysBundle() throws Exception {
+ String jsonInput = "{\"payload\": \"{\\\"ciphertext\\" +
+ "\":\\\"L1yRyZBkVYKXC1cTpeUqqfmKg" +
+ "CinYV9YntGiG0PfYZSTLQ2s86WPI0VBb" +
+ "QbLZfx7udk6sf6CFE4w5EgiPx0XP3Fbj" +
+ "L7r4qIT0vjbAOrLKedZwA3cgiquc+PXM" +
+ "Etml8B4Dfm0crJK0iROlRkb+lePAYkzI" +
+ "iQn5Ba8mSWQEFoLy3zAcfCYXumA7E0Fj" +
+ "XYD+TqTG5bqYJY4zvPaB9mn9y3WHw==\\" +
+ "\",\\\"IV\\\":\\\"Jjb2oVI5uvvFfm" +
+ "ZYRY4GaA==\\\",\\\"hmac\\\":\\\"" +
+ "0b59731cb1aaedc85f54917b7058f361" +
+ "60826b70050b0d70cd42b0b609b1d717" +
+ "\\\"}\", \"id\": \"keys\", \"mod" +
+ "ified\": 1320183463.91}";
+ String username = "b6evr62dptbxz7fvebek7btljyu322wp";
+ String friendlyBase32SyncKey = "basuxv2426eqj7frhvpcwkavdi";
+ String expectedDecryptedText = "{\"default\":[\"K8fV6PHG8RgugfHe" +
+ "xGesbzTeOs2o12crN/G3bz0Bx1M=\",\"" +
+ "nbceuI6w1RJbBzh+iCJHEs8p4lElsOma" +
+ "yUhx+OztVgM=\"],\"collections\":" +
+ "{},\"collection\":\"crypto\",\"i" +
+ "d\":\"keys\"}";
+ String expectedBase64EncryptionKey = "K8fV6PHG8RgugfHexGesbzTeOs2o12cr" +
+ "N/G3bz0Bx1M=";
+ String expectedBase64HmacKey = "nbceuI6w1RJbBzh+iCJHEs8p4lElsOma" +
+ "yUhx+OztVgM=";
+
+ KeyBundle syncKeyBundle = new KeyBundle(username, friendlyBase32SyncKey);
+
+ ExtendedJSONObject json = new ExtendedJSONObject(jsonInput);
+ assertEquals("keys", json.get("id"));
+
+ CryptoRecord encrypted = CryptoRecord.fromJSONRecord(jsonInput);
+ encrypted.keyBundle = syncKeyBundle;
+ CryptoRecord decrypted = encrypted.decrypt();
+
+ // We don't necessarily produce exactly the same JSON but we do have the same values.
+ ExtendedJSONObject expectedJson = new ExtendedJSONObject(expectedDecryptedText);
+ assertEquals(expectedJson.get("id"), decrypted.payload.get("id"));
+ assertEquals(expectedJson.get("default"), decrypted.payload.get("default"));
+ assertEquals(expectedJson.get("collection"), decrypted.payload.get("collection"));
+ assertEquals(expectedJson.get("collections"), decrypted.payload.get("collections"));
+
+ // Check that the extracted keys were as expected.
+ JSONArray keys = new ExtendedJSONObject(decrypted.payload.toJSONString()).getArray("default");
+ KeyBundle keyBundle = KeyBundle.fromBase64EncodedKeys((String)keys.get(0), (String)keys.get(1));
+
+ assertArrayEquals(Base64.decodeBase64(expectedBase64EncryptionKey.getBytes("UTF-8")), keyBundle.getEncryptionKey());
+ assertArrayEquals(Base64.decodeBase64(expectedBase64HmacKey.getBytes("UTF-8")), keyBundle.getHMACKey());
+ }
+
+ @Test
+ public void testTTL() throws UnsupportedEncodingException, CryptoException {
+ Record historyRecord = new HistoryRecord();
+ CryptoRecord cryptoRecord = historyRecord.getEnvelope();
+ assertEquals(historyRecord.ttl, cryptoRecord.ttl);
+
+ // Very important that ttls are set in outbound envelopes.
+ JSONObject o = cryptoRecord.toJSONObject();
+ assertEquals(cryptoRecord.ttl, o.get("ttl"));
+
+ // Most important of all, outbound encrypted record envelopes.
+ KeyBundle keyBundle = KeyBundle.withRandomKeys();
+ cryptoRecord.keyBundle = keyBundle;
+ cryptoRecord.encrypt();
+ assertEquals(historyRecord.ttl, cryptoRecord.ttl); // Should be preserved.
+ o = cryptoRecord.toJSONObject();
+ assertEquals(cryptoRecord.ttl, o.get("ttl"));
+
+ // But we should ignore negative ttls.
+ Record clientRecord = new ClientRecord();
+ clientRecord.ttl = -1; // Don't ttl this record.
+ o = clientRecord.getEnvelope().toJSONObject();
+ assertNull(o.get("ttl"));
+
+ // But we should ignore negative ttls in outbound encrypted record envelopes.
+ cryptoRecord = clientRecord.getEnvelope();
+ cryptoRecord.keyBundle = keyBundle;
+ cryptoRecord.encrypt();
+ o = cryptoRecord.toJSONObject();
+ assertNull(o.get("ttl"));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecord.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecord.java
new file mode 100644
index 000000000..473534aac
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecord.java
@@ -0,0 +1,330 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.db.Tab;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.mozilla.gecko.sync.repositories.domain.HistoryRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+import org.mozilla.gecko.sync.repositories.domain.RecordParseException;
+import org.mozilla.gecko.sync.repositories.domain.TabsRecord;
+
+import java.io.IOException;
+import java.util.ArrayList;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+@RunWith(TestRunner.class)
+public class TestRecord {
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testQueryRecord() throws NonObjectJSONException, IOException {
+ final String expectedGUID = "Bl3n3gpKag3s";
+ final String testRecord =
+ "{\"id\":\"" + expectedGUID + "\"," +
+ " \"type\":\"query\"," +
+ " \"title\":\"Downloads\"," +
+ " \"parentName\":\"\"," +
+ " \"bmkUri\":\"place:transition=7&sort=4\"," +
+ " \"tags\":[]," +
+ " \"keyword\":null," +
+ " \"description\":null," +
+ " \"loadInSidebar\":false," +
+ " \"parentid\":\"BxfRgGiNeITG\"}";
+
+ final ExtendedJSONObject o = new ExtendedJSONObject(testRecord);
+ final CryptoRecord cr = new CryptoRecord(o);
+ cr.guid = expectedGUID;
+ cr.lastModified = System.currentTimeMillis();
+ cr.collection = "bookmarks";
+
+ final BookmarkRecord r = new BookmarkRecord("Bl3n3gpKag3s", "bookmarks");
+ r.initFromEnvelope(cr);
+ assertEquals(expectedGUID, r.guid);
+ assertEquals("query", r.type);
+ assertEquals("places:uri=place%3Atransition%3D7%26sort%3D4", r.bookmarkURI);
+
+ // Check that we get the same bookmark URI out the other end,
+ // once we've parsed it into a CryptoRecord, a BookmarkRecord, then
+ // back into a CryptoRecord.
+ assertEquals("place:transition=7&sort=4", r.getEnvelope().payload.getString("bmkUri"));
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testRecordGUIDs() {
+ for (int i = 0; i < 50; ++i) {
+ CryptoRecord cryptoRecord = new HistoryRecord().getEnvelope();
+ assertEquals(12, cryptoRecord.guid.length());
+ }
+ }
+
+ @Test
+ public void testRecordEquality() {
+ long now = System.currentTimeMillis();
+ BookmarkRecord bOne = new BookmarkRecord("abcdefghijkl", "bookmarks", now , false);
+ BookmarkRecord bTwo = new BookmarkRecord("abcdefghijkl", "bookmarks", now , false);
+ HistoryRecord hOne = new HistoryRecord("mbcdefghijkm", "history", now , false);
+ HistoryRecord hTwo = new HistoryRecord("mbcdefghijkm", "history", now , false);
+
+ // Identical records.
+ assertFalse(bOne == bTwo);
+ assertTrue(bOne.equals(bTwo));
+ assertTrue(bOne.equalPayloads(bTwo));
+ assertTrue(bOne.congruentWith(bTwo));
+ assertTrue(bTwo.equals(bOne));
+ assertTrue(bTwo.equalPayloads(bOne));
+ assertTrue(bTwo.congruentWith(bOne));
+
+ // Null checking.
+ assertFalse(bOne.equals(null));
+ assertFalse(bOne.equalPayloads(null));
+ assertFalse(bOne.congruentWith(null));
+
+ // Different types.
+ hOne.guid = bOne.guid;
+ assertFalse(bOne.equals(hOne));
+ assertFalse(bOne.equalPayloads(hOne));
+ assertFalse(bOne.congruentWith(hOne));
+ hOne.guid = hTwo.guid;
+
+ // Congruent androidID.
+ bOne.androidID = 1;
+ assertFalse(bOne.equals(bTwo));
+ assertTrue(bOne.equalPayloads(bTwo));
+ assertTrue(bOne.congruentWith(bTwo));
+ assertFalse(bTwo.equals(bOne));
+ assertTrue(bTwo.equalPayloads(bOne));
+ assertTrue(bTwo.congruentWith(bOne));
+
+ // Non-congruent androidID.
+ bTwo.androidID = 2;
+ assertFalse(bOne.equals(bTwo));
+ assertTrue(bOne.equalPayloads(bTwo));
+ assertFalse(bOne.congruentWith(bTwo));
+ assertFalse(bTwo.equals(bOne));
+ assertTrue(bTwo.equalPayloads(bOne));
+ assertFalse(bTwo.congruentWith(bOne));
+
+ // Identical androidID.
+ bOne.androidID = 2;
+ assertTrue(bOne.equals(bTwo));
+ assertTrue(bOne.equalPayloads(bTwo));
+ assertTrue(bOne.congruentWith(bTwo));
+ assertTrue(bTwo.equals(bOne));
+ assertTrue(bTwo.equalPayloads(bOne));
+ assertTrue(bTwo.congruentWith(bOne));
+
+ // Different times.
+ bTwo.lastModified += 1000;
+ assertFalse(bOne.equals(bTwo));
+ assertTrue(bOne.equalPayloads(bTwo));
+ assertTrue(bOne.congruentWith(bTwo));
+ assertFalse(bTwo.equals(bOne));
+ assertTrue(bTwo.equalPayloads(bOne));
+ assertTrue(bTwo.congruentWith(bOne));
+
+ // Add some visits.
+ JSONObject v1 = fakeVisit(now - 1000);
+ JSONObject v2 = fakeVisit(now - 500);
+
+ hOne.fennecDateVisited = now + 2000;
+ hOne.fennecVisitCount = 1;
+ assertFalse(hOne.equals(hTwo));
+ assertTrue(hOne.equalPayloads(hTwo));
+ assertTrue(hOne.congruentWith(hTwo));
+ addVisit(hOne, v1);
+ assertFalse(hOne.equals(hTwo));
+ assertFalse(hOne.equalPayloads(hTwo));
+ assertTrue(hOne.congruentWith(hTwo));
+ addVisit(hTwo, v2);
+ assertFalse(hOne.equals(hTwo));
+ assertFalse(hOne.equalPayloads(hTwo));
+ assertTrue(hOne.congruentWith(hTwo));
+
+ // Now merge the visits.
+ addVisit(hTwo, v1);
+ addVisit(hOne, v2);
+ assertFalse(hOne.equals(hTwo));
+ assertTrue(hOne.equalPayloads(hTwo));
+ assertTrue(hOne.congruentWith(hTwo));
+ hTwo.fennecDateVisited = hOne.fennecDateVisited;
+ hTwo.fennecVisitCount = hOne.fennecVisitCount = 2;
+ assertTrue(hOne.equals(hTwo));
+ assertTrue(hOne.equalPayloads(hTwo));
+ assertTrue(hOne.congruentWith(hTwo));
+ }
+
+ @SuppressWarnings("unchecked")
+ private void addVisit(HistoryRecord r, JSONObject visit) {
+ if (r.visits == null) {
+ r.visits = new JSONArray();
+ }
+ r.visits.add(visit);
+ }
+
+ @SuppressWarnings("unchecked")
+ private JSONObject fakeVisit(long time) {
+ JSONObject object = new JSONObject();
+ object.put("type", 1L);
+ object.put("date", time * 1000);
+ return object;
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testTabParsing() throws Exception {
+ String json = "{\"title\":\"mozilla-central mozilla/browser/base/content/syncSetup.js\"," +
+ " \"urlHistory\":[\"http://mxr.mozilla.org/mozilla-central/source/browser/base/content/syncSetup.js#72\"]," +
+ " \"icon\":\"http://mxr.mozilla.org/mxr.png\"," +
+ " \"lastUsed\":\"1306374531\"}";
+ Tab tab = TabsRecord.tabFromJSONObject(new ExtendedJSONObject(json).object);
+
+ assertEquals("mozilla-central mozilla/browser/base/content/syncSetup.js", tab.title);
+ assertEquals("http://mxr.mozilla.org/mxr.png", tab.icon);
+ assertEquals("http://mxr.mozilla.org/mozilla-central/source/browser/base/content/syncSetup.js#72", tab.history.get(0));
+ assertEquals(1306374531000L, tab.lastUsed);
+
+ String zeroJSON = "{\"title\":\"a\"," +
+ " \"urlHistory\":[\"http://example.com\"]," +
+ " \"icon\":\"\"," +
+ " \"lastUsed\":0}";
+ Tab zero = TabsRecord.tabFromJSONObject(new ExtendedJSONObject(zeroJSON).object);
+
+ assertEquals("a", zero.title);
+ assertEquals("", zero.icon);
+ assertEquals("http://example.com", zero.history.get(0));
+ assertEquals(0L, zero.lastUsed);
+ }
+
+ @SuppressWarnings({ "unchecked", "static-method" })
+ @Test
+ public void testTabsRecordCreation() throws Exception {
+ final TabsRecord record = new TabsRecord("testGuid");
+ record.clientName = "test client name";
+
+ final JSONArray history1 = new JSONArray();
+ history1.add("http://test.com/test1.html");
+ final Tab tab1 = new Tab("test title 1", "http://test.com/test1.png", history1, 1000);
+
+ final JSONArray history2 = new JSONArray();
+ history2.add("http://test.com/test2.html#1");
+ history2.add("http://test.com/test2.html#2");
+ history2.add("http://test.com/test2.html#3");
+ final Tab tab2 = new Tab("test title 2", "http://test.com/test2.png", history2, 2000);
+
+ record.tabs = new ArrayList<Tab>();
+ record.tabs.add(tab1);
+ record.tabs.add(tab2);
+
+ final TabsRecord parsed = new TabsRecord();
+ parsed.initFromEnvelope(CryptoRecord.fromJSONRecord(record.getEnvelope().toJSONString()));
+
+ assertEquals(record.guid, parsed.guid);
+ assertEquals(record.clientName, parsed.clientName);
+ assertEquals(record.tabs, parsed.tabs);
+
+ // Verify that equality test doesn't always return true.
+ parsed.tabs.get(0).history.add("http://test.com/different.html");
+ assertFalse(record.tabs.equals(parsed.tabs));
+ }
+
+ public static class URITestBookmarkRecord extends BookmarkRecord {
+ public static void doTest() {
+ assertEquals("places:uri=abc%26def+baz&p1=123&p2=bar+baz",
+ encodeUnsupportedTypeURI("abc&def baz", "p1", "123", "p2", "bar baz"));
+ assertEquals("places:uri=abc%26def+baz&p1=123",
+ encodeUnsupportedTypeURI("abc&def baz", "p1", "123", null, "bar baz"));
+ assertEquals("places:p1=123",
+ encodeUnsupportedTypeURI(null, "p1", "123", "p2", null));
+ }
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testEncodeURI() {
+ URITestBookmarkRecord.doTest();
+ }
+
+ private static final String payload =
+ "{\"id\":\"M5bwUKK8hPyF\"," +
+ "\"type\":\"livemark\"," +
+ "\"siteUri\":\"http://www.bbc.co.uk/go/rss/int/news/-/news/\"," +
+ "\"feedUri\":\"http://fxfeeds.mozilla.com/en-US/firefox/headlines.xml\"," +
+ "\"parentName\":\"Bookmarks Toolbar\"," +
+ "\"parentid\":\"toolbar\"," +
+ "\"title\":\"Latest Headlines\"," +
+ "\"description\":\"\"," +
+ "\"children\":" +
+ "[\"7oBdEZB-8BMO\", \"SUd1wktMNCTB\", \"eZe4QWzo1BcY\", \"YNBhGwhVnQsN\"," +
+ "\"mNTdpgoRZMbW\", \"-L8Vci6CbkJY\", \"bVzudKSQERc1\", \"Gxl9lb4DXsmL\"," +
+ "\"3Qr13GucOtEh\"]}";
+
+ public class PayloadBookmarkRecord extends BookmarkRecord {
+ public PayloadBookmarkRecord() {
+ super("abcdefghijkl", "bookmarks", 1234, false);
+ }
+
+ public void doTest() throws NonObjectJSONException, IOException {
+ this.initFromPayload(new ExtendedJSONObject(payload));
+ assertEquals("abcdefghijkl", this.guid); // Ignores payload.
+ assertEquals("livemark", this.type);
+ assertEquals("Bookmarks Toolbar", this.parentName);
+ assertEquals("toolbar", this.parentID);
+ assertEquals("", this.description);
+ assertEquals(null, this.children);
+
+ final String encodedSite = "http%3A%2F%2Fwww.bbc.co.uk%2Fgo%2Frss%2Fint%2Fnews%2F-%2Fnews%2F";
+ final String encodedFeed = "http%3A%2F%2Ffxfeeds.mozilla.com%2Fen-US%2Ffirefox%2Fheadlines.xml";
+ final String expectedURI = "places:siteUri=" + encodedSite + "&feedUri=" + encodedFeed;
+ assertEquals(expectedURI, this.bookmarkURI);
+ }
+ }
+
+ @Test
+ public void testUnusualBookmarkRecords() throws NonObjectJSONException, IOException {
+ PayloadBookmarkRecord record = new PayloadBookmarkRecord();
+ record.doTest();
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testTTL() {
+ Record record = new HistoryRecord();
+ assertEquals(HistoryRecord.HISTORY_TTL, record.ttl);
+
+ // ClientRecords are transient, HistoryRecords are not.
+ Record clientRecord = new ClientRecord();
+ assertTrue(clientRecord.ttl < record.ttl);
+
+ CryptoRecord cryptoRecord = record.getEnvelope();
+ assertEquals(record.ttl, cryptoRecord.ttl);
+ }
+
+ @Test
+ public void testStringModified() throws Exception {
+ // modified member is a string, expected a floating point number with 2
+ // decimal digits.
+ String badJson = "{\"sortindex\":\"0\",\"payload\":\"{\\\"syncID\\\":\\\"ZJOqMBjhBthH\\\",\\\"storageVersion\\\":5,\\\"engines\\\":{\\\"clients\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"4oTBXG20rJH5\\\"},\\\"bookmarks\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"JiMJXy8xI3fr\\\"},\\\"forms\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"J17vSloroXBU\\\"},\\\"history\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"y1HgpbSc3LJT\\\"},\\\"passwords\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"v3y-RidcCuT5\\\"},\\\"prefs\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"LvfqmT7cUUm4\\\"},\\\"tabs\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"MKMRlBah2d9D\\\"},\\\"addons\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"Ih2hhRrcGjh4\\\"}}}\",\"id\":\"global\",\"modified\":\"1370689360.28\"}";
+ try {
+ CryptoRecord.fromJSONRecord(badJson);
+ fail("Expected exception.");
+ } catch (Exception e) {
+ assertTrue(e instanceof RecordParseException);
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecordsChannel.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecordsChannel.java
new file mode 100644
index 000000000..69d3c32e7
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecordsChannel.java
@@ -0,0 +1,229 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.SynchronizerHelpers.FailFetchWBORepository;
+import org.mozilla.android.sync.test.helpers.ExpectSuccessRepositorySessionCreationDelegate;
+import org.mozilla.android.sync.test.helpers.ExpectSuccessRepositorySessionFinishDelegate;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WBORepository;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
+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.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.synchronizer.RecordsChannel;
+import org.mozilla.gecko.sync.synchronizer.RecordsChannelDelegate;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestRecordsChannel {
+
+ protected WBORepository remote;
+ protected WBORepository local;
+
+ protected RepositorySession source;
+ protected RepositorySession sink;
+ protected RecordsChannelDelegate rcDelegate;
+
+ protected AtomicInteger numFlowFetchFailed;
+ protected AtomicInteger numFlowStoreFailed;
+ protected AtomicInteger numFlowCompleted;
+ protected AtomicBoolean flowBeginFailed;
+ protected AtomicBoolean flowFinishFailed;
+
+ public void doFlow(final Repository remote, final Repository local) throws Exception {
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ remote.createSession(new ExpectSuccessRepositorySessionCreationDelegate(WaitHelper.getTestWaiter()) {
+ @Override
+ public void onSessionCreated(RepositorySession session) {
+ source = session;
+ local.createSession(new ExpectSuccessRepositorySessionCreationDelegate(WaitHelper.getTestWaiter()) {
+ @Override
+ public void onSessionCreated(RepositorySession session) {
+ sink = session;
+ WaitHelper.getTestWaiter().performNotify();
+ }
+ }, null);
+ }
+ }, null);
+ }
+ });
+
+ assertNotNull(source);
+ assertNotNull(sink);
+
+ numFlowFetchFailed = new AtomicInteger(0);
+ numFlowStoreFailed = new AtomicInteger(0);
+ numFlowCompleted = new AtomicInteger(0);
+ flowBeginFailed = new AtomicBoolean(false);
+ flowFinishFailed = new AtomicBoolean(false);
+
+ rcDelegate = new RecordsChannelDelegate() {
+ @Override
+ public void onFlowFetchFailed(RecordsChannel recordsChannel, Exception ex) {
+ numFlowFetchFailed.incrementAndGet();
+ }
+
+ @Override
+ public void onFlowStoreFailed(RecordsChannel recordsChannel, Exception ex, String recordGuid) {
+ numFlowStoreFailed.incrementAndGet();
+ }
+
+ @Override
+ public void onFlowFinishFailed(RecordsChannel recordsChannel, Exception ex) {
+ flowFinishFailed.set(true);
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public void onFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) {
+ numFlowCompleted.incrementAndGet();
+ try {
+ sink.finish(new ExpectSuccessRepositorySessionFinishDelegate(WaitHelper.getTestWaiter()) {
+ @Override
+ public void onFinishSucceeded(RepositorySession session, RepositorySessionBundle bundle) {
+ try {
+ source.finish(new ExpectSuccessRepositorySessionFinishDelegate(WaitHelper.getTestWaiter()) {
+ @Override
+ public void onFinishSucceeded(RepositorySession session, RepositorySessionBundle bundle) {
+ performNotify();
+ }
+ });
+ } catch (InactiveSessionException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+ });
+ } catch (InactiveSessionException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+
+ @Override
+ public void onFlowBeginFailed(RecordsChannel recordsChannel, Exception ex) {
+ flowBeginFailed.set(true);
+ WaitHelper.getTestWaiter().performNotify();
+ }
+ };
+
+ final RecordsChannel rc = new RecordsChannel(source, sink, rcDelegate);
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ rc.beginAndFlow();
+ } catch (InvalidSessionTransitionException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+ });
+ }
+
+ public static final BookmarkRecord[] inbounds = new BookmarkRecord[] {
+ new BookmarkRecord("inboundSucc1", "bookmarks", 1, false),
+ new BookmarkRecord("inboundSucc2", "bookmarks", 1, false),
+ new BookmarkRecord("inboundFail1", "bookmarks", 1, false),
+ new BookmarkRecord("inboundSucc3", "bookmarks", 1, false),
+ new BookmarkRecord("inboundSucc4", "bookmarks", 1, false),
+ new BookmarkRecord("inboundFail2", "bookmarks", 1, false),
+ };
+ public static final BookmarkRecord[] outbounds = new BookmarkRecord[] {
+ new BookmarkRecord("outboundSucc1", "bookmarks", 1, false),
+ new BookmarkRecord("outboundSucc2", "bookmarks", 1, false),
+ new BookmarkRecord("outboundSucc3", "bookmarks", 1, false),
+ new BookmarkRecord("outboundSucc4", "bookmarks", 1, false),
+ new BookmarkRecord("outboundSucc5", "bookmarks", 1, false),
+ new BookmarkRecord("outboundFail6", "bookmarks", 1, false),
+ };
+
+ protected WBORepository empty() {
+ WBORepository repo = new SynchronizerHelpers.TrackingWBORepository();
+ return repo;
+ }
+
+ protected WBORepository full() {
+ WBORepository repo = new SynchronizerHelpers.TrackingWBORepository();
+ for (BookmarkRecord outbound : outbounds) {
+ repo.wbos.put(outbound.guid, outbound);
+ }
+ return repo;
+ }
+
+ protected WBORepository failingFetch() {
+ WBORepository repo = new FailFetchWBORepository();
+ for (BookmarkRecord outbound : outbounds) {
+ repo.wbos.put(outbound.guid, outbound);
+ }
+ return repo;
+ }
+
+ @Test
+ public void testSuccess() throws Exception {
+ WBORepository source = full();
+ WBORepository sink = empty();
+ doFlow(source, sink);
+ assertEquals(1, numFlowCompleted.get());
+ assertEquals(0, numFlowFetchFailed.get());
+ assertEquals(0, numFlowStoreFailed.get());
+ assertEquals(source.wbos, sink.wbos);
+ }
+
+ @Test
+ public void testFetchFail() throws Exception {
+ WBORepository source = failingFetch();
+ WBORepository sink = empty();
+ doFlow(source, sink);
+ assertEquals(1, numFlowCompleted.get());
+ assertTrue(numFlowFetchFailed.get() > 0);
+ assertEquals(0, numFlowStoreFailed.get());
+ assertTrue(sink.wbos.size() < 6);
+ }
+
+ @Test
+ public void testStoreSerialFail() throws Exception {
+ WBORepository source = full();
+ WBORepository sink = new SynchronizerHelpers.SerialFailStoreWBORepository();
+ doFlow(source, sink);
+ assertEquals(1, numFlowCompleted.get());
+ assertEquals(0, numFlowFetchFailed.get());
+ assertEquals(1, numFlowStoreFailed.get());
+ assertEquals(5, sink.wbos.size());
+ }
+
+ @Test
+ public void testStoreBatchesFail() throws Exception {
+ WBORepository source = full();
+ WBORepository sink = new SynchronizerHelpers.BatchFailStoreWBORepository(3);
+ doFlow(source, sink);
+ assertEquals(1, numFlowCompleted.get());
+ assertEquals(0, numFlowFetchFailed.get());
+ assertEquals(3, numFlowStoreFailed.get()); // One batch fails.
+ assertEquals(3, sink.wbos.size()); // One batch succeeds.
+ }
+
+
+ @Test
+ public void testStoreOneBigBatchFail() throws Exception {
+ WBORepository source = full();
+ WBORepository sink = new SynchronizerHelpers.BatchFailStoreWBORepository(50);
+ doFlow(source, sink);
+ assertEquals(1, numFlowCompleted.get());
+ assertEquals(0, numFlowFetchFailed.get());
+ assertEquals(6, numFlowStoreFailed.get()); // One (big) batch fails.
+ assertEquals(0, sink.wbos.size()); // No batches succeed.
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestResetCommands.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestResetCommands.java
new file mode 100644
index 000000000..22bcc5093
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestResetCommands.java
@@ -0,0 +1,153 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import android.content.SharedPreferences;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.DefaultGlobalSessionCallback;
+import org.mozilla.gecko.background.testhelpers.MockPrefsGlobalSession;
+import org.mozilla.gecko.background.testhelpers.MockServerSyncStage;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.CommandProcessor;
+import org.mozilla.gecko.sync.EngineSettings;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.MetaGlobalException;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.SyncConfigurationException;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+
+import java.io.IOException;
+import java.util.HashMap;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Test that reset commands properly invoke the reset methods on the correct stage.
+ */
+@RunWith(TestRunner.class)
+public class TestResetCommands {
+ private static final String TEST_USERNAME = "johndoe";
+ private static final String TEST_PASSWORD = "password";
+ private static final String TEST_SYNC_KEY = "abcdeabcdeabcdeabcdeabcdea";
+
+ public static void performNotify() {
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ public static void performNotify(Throwable e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+
+ public static void performWait(Runnable runnable) {
+ WaitHelper.getTestWaiter().performWait(runnable);
+ }
+
+ @Before
+ public void setUp() {
+ assertTrue(WaitHelper.getTestWaiter().isIdle());
+ }
+
+ @Test
+ public void testHandleResetCommand() throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, CryptoException {
+ // Create a global session.
+ // Set up stage mappings for a real stage name (because they're looked up by name
+ // in an enumeration) pointing to our fake stage.
+ // Send a reset command.
+ // Verify that reset is called on our stage.
+
+ class Result {
+ public boolean called = false;
+ }
+
+ final Result yes = new Result();
+ final Result no = new Result();
+ final GlobalSessionCallback callback = createGlobalSessionCallback();
+
+ // So we can poke at stages separately.
+ final HashMap<Stage, GlobalSyncStage> stagesToRun = new HashMap<Stage, GlobalSyncStage>();
+
+ // Side-effect: modifies global command processor.
+ final SharedPreferences prefs = new MockSharedPreferences();
+ final SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), prefs);
+ config.syncKeyBundle = new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY);
+ final GlobalSession session = new MockPrefsGlobalSession(config, callback, null, null) {
+ @Override
+ public boolean isEngineRemotelyEnabled(String engineName,
+ EngineSettings engineSettings)
+ throws MetaGlobalException {
+ return true;
+ }
+
+ @Override
+ public void advance() {
+ // So we don't proceed and run other stages.
+ }
+
+ @Override
+ public void prepareStages() {
+ this.stages = stagesToRun;
+ }
+ };
+
+ final MockServerSyncStage stageGetsReset = new MockServerSyncStage() {
+ @Override
+ public void resetLocal() {
+ yes.called = true;
+ }
+ };
+
+ final MockServerSyncStage stageNotReset = new MockServerSyncStage() {
+ @Override
+ public void resetLocal() {
+ no.called = true;
+ }
+ };
+
+ stagesToRun.put(Stage.syncBookmarks, stageGetsReset);
+ stagesToRun.put(Stage.syncHistory, stageNotReset);
+
+ final String resetBookmarks = "{\"args\":[\"bookmarks\"],\"command\":\"resetEngine\"}";
+ ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(resetBookmarks);
+ CommandProcessor processor = CommandProcessor.getProcessor();
+ processor.processCommand(session, unparsedCommand);
+
+ assertTrue(yes.called);
+ assertFalse(no.called);
+ }
+
+ public void testHandleWipeCommand() {
+ // TODO
+ }
+
+ private static GlobalSessionCallback createGlobalSessionCallback() {
+ return new DefaultGlobalSessionCallback() {
+
+ @Override
+ public void handleAborted(GlobalSession globalSession, String reason) {
+ performNotify(new Exception("Aborted"));
+ }
+
+ @Override
+ public void handleError(GlobalSession globalSession, Exception ex) {
+ performNotify(ex);
+ }
+
+ @Override
+ public void handleSuccess(GlobalSession globalSession) {
+ }
+ };
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServer11RepositorySession.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServer11RepositorySession.java
new file mode 100644
index 000000000..96a366c2d
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServer11RepositorySession.java
@@ -0,0 +1,231 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.SynchronizerHelpers.TrackingWBORepository;
+import org.mozilla.android.sync.test.helpers.BaseTestStorageRequestDelegate;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.InfoConfiguration;
+import org.mozilla.gecko.sync.JSONRecordFetcher;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+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.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.FetchFailedException;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.Server11Repository;
+import org.mozilla.gecko.sync.repositories.StoreFailedException;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecordFactory;
+import org.mozilla.gecko.sync.stage.SafeConstrainedServer11Repository;
+import org.mozilla.gecko.sync.synchronizer.ServerLocalSynchronizer;
+import org.mozilla.gecko.sync.synchronizer.Synchronizer;
+import org.simpleframework.http.ContentType;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestServer11RepositorySession {
+
+ public class POSTMockServer extends MockServer {
+ @Override
+ public void handle(Request request, Response response) {
+ try {
+ String content = request.getContent();
+ System.out.println("Content:" + content);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ ContentType contentType = request.getContentType();
+ System.out.println("Content-Type:" + contentType);
+ super.handle(request, response, 200, "{success:[]}");
+ }
+ }
+
+ private static final int TEST_PORT = HTTPServerTestHelper.getTestPort();
+ private static final String TEST_SERVER = "http://localhost:" + TEST_PORT + "/";
+ static final String LOCAL_BASE_URL = TEST_SERVER + "1.1/n6ec3u5bee3tixzp2asys7bs6fve4jfw/";
+ static final String LOCAL_INFO_BASE_URL = LOCAL_BASE_URL + "info/";
+ static final String LOCAL_COUNTS_URL = LOCAL_INFO_BASE_URL + "collection_counts";
+
+ // Corresponds to rnewman+atest1@mozilla.com, local.
+ static final String TEST_USERNAME = "n6ec3u5bee3tixzp2asys7bs6fve4jfw";
+ static final String TEST_PASSWORD = "passowrd";
+ static final String SYNC_KEY = "eh7ppnb82iwr5kt3z3uyi5vr44";
+
+ public final AuthHeaderProvider authHeaderProvider = new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD);
+ protected final InfoCollections infoCollections = new InfoCollections();
+ protected final InfoConfiguration infoConfiguration = new InfoConfiguration();
+
+ // Few-second timeout so that our longer operations don't time out and cause spurious error-handling results.
+ private static final int SHORT_TIMEOUT = 10000;
+
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD);
+ }
+
+ private HTTPServerTestHelper data = new HTTPServerTestHelper();
+
+ public class TestSyncStorageRequestDelegate extends
+ BaseTestStorageRequestDelegate {
+ public TestSyncStorageRequestDelegate(String username, String password) {
+ super(username, password);
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse res) {
+ assertTrue(res.wasSuccessful());
+ assertTrue(res.httpResponse().containsHeader("X-Weave-Timestamp"));
+ BaseResource.consumeEntity(res);
+ data.stopHTTPServer();
+ }
+ }
+
+ @SuppressWarnings("static-method")
+ protected TrackingWBORepository getLocal(int numRecords) {
+ final TrackingWBORepository local = new TrackingWBORepository();
+ for (int i = 0; i < numRecords; i++) {
+ BookmarkRecord outbound = new BookmarkRecord("outboundFail" + i, "bookmarks", 1, false);
+ local.wbos.put(outbound.guid, outbound);
+ }
+ return local;
+ }
+
+ protected Exception doSynchronize(MockServer server) throws Exception {
+ final String COLLECTION = "test";
+
+ final TrackingWBORepository local = getLocal(100);
+ final Server11Repository remote = new Server11Repository(COLLECTION, getCollectionURL(COLLECTION), authHeaderProvider, infoCollections, infoConfiguration);
+ KeyBundle collectionKey = new KeyBundle(TEST_USERNAME, SYNC_KEY);
+ Crypto5MiddlewareRepository cryptoRepo = new Crypto5MiddlewareRepository(remote, collectionKey);
+ cryptoRepo.recordFactory = new BookmarkRecordFactory();
+
+ final Synchronizer synchronizer = new ServerLocalSynchronizer();
+ synchronizer.repositoryA = cryptoRepo;
+ synchronizer.repositoryB = local;
+
+ data.startHTTPServer(server);
+ try {
+ Exception e = TestServerLocalSynchronizer.doSynchronize(synchronizer);
+ return e;
+ } finally {
+ data.stopHTTPServer();
+ }
+ }
+
+ protected String getCollectionURL(String collection) {
+ return LOCAL_BASE_URL + "/storage/" + collection;
+ }
+
+ @Test
+ public void testFetchFailure() throws Exception {
+ MockServer server = new MockServer(404, "error");
+ Exception e = doSynchronize(server);
+ assertNotNull(e);
+ assertEquals(FetchFailedException.class, e.getClass());
+ }
+
+ @Test
+ public void testStorePostSuccessWithFailingRecords() throws Exception {
+ MockServer server = new MockServer(200, "{ modified: \" + " + Utils.millisecondsToDecimalSeconds(System.currentTimeMillis()) + ", " +
+ "success: []," +
+ "failed: { outboundFail2: [] } }");
+ Exception e = doSynchronize(server);
+ assertNotNull(e);
+ assertEquals(StoreFailedException.class, e.getClass());
+ }
+
+ @Test
+ public void testStorePostFailure() throws Exception {
+ MockServer server = new MockServer() {
+ @Override
+ public void handle(Request request, Response response) {
+ if (request.getMethod().equals("POST")) {
+ this.handle(request, response, 404, "missing");
+ }
+ this.handle(request, response, 200, "success");
+ return;
+ }
+ };
+
+ Exception e = doSynchronize(server);
+ assertNotNull(e);
+ assertEquals(StoreFailedException.class, e.getClass());
+ }
+
+ @Test
+ public void testConstraints() throws Exception {
+ MockServer server = new MockServer() {
+ @Override
+ public void handle(Request request, Response response) {
+ if (request.getMethod().equals("GET")) {
+ if (request.getPath().getPath().endsWith("/info/collection_counts")) {
+ this.handle(request, response, 200, "{\"bookmarks\": 5001}");
+ }
+ }
+ this.handle(request, response, 400, "NOOOO");
+ }
+ };
+ final JSONRecordFetcher countsFetcher = new JSONRecordFetcher(LOCAL_COUNTS_URL, getAuthHeaderProvider());
+ String collection = "bookmarks";
+ final SafeConstrainedServer11Repository remote = new SafeConstrainedServer11Repository(collection,
+ getCollectionURL(collection),
+ getAuthHeaderProvider(),
+ infoCollections,
+ infoConfiguration,
+ 5000, 5000, "sortindex", countsFetcher);
+
+ data.startHTTPServer(server);
+ final AtomicBoolean out = new AtomicBoolean(false);
+
+ // Verify that shouldSkip returns true due to a fetch of too large counts,
+ // rather than due to a timeout failure waiting to fetch counts.
+ try {
+ WaitHelper.getTestWaiter().performWait(
+ SHORT_TIMEOUT,
+ new Runnable() {
+ @Override
+ public void run() {
+ remote.createSession(new RepositorySessionCreationDelegate() {
+ @Override
+ public void onSessionCreated(RepositorySession session) {
+ out.set(session.shouldSkip());
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public void onSessionCreateFailed(Exception ex) {
+ WaitHelper.getTestWaiter().performNotify(ex);
+ }
+
+ @Override
+ public RepositorySessionCreationDelegate deferredCreationDelegate() {
+ return this;
+ }
+ }, null);
+ }
+ });
+ assertTrue(out.get());
+ } finally {
+ data.stopHTTPServer();
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServerLocalSynchronizer.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServerLocalSynchronizer.java
new file mode 100644
index 000000000..267798672
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServerLocalSynchronizer.java
@@ -0,0 +1,237 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.SynchronizerHelpers.BatchFailStoreWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.BeginErrorWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.BeginFailedException;
+import org.mozilla.android.sync.test.SynchronizerHelpers.FailFetchWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.FinishErrorWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.FinishFailedException;
+import org.mozilla.android.sync.test.SynchronizerHelpers.SerialFailStoreWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.TrackingWBORepository;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WBORepository;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.FetchFailedException;
+import org.mozilla.gecko.sync.repositories.StoreFailedException;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.synchronizer.ServerLocalSynchronizer;
+import org.mozilla.gecko.sync.synchronizer.Synchronizer;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerDelegate;
+
+import java.util.ArrayList;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+@RunWith(TestRunner.class)
+public class TestServerLocalSynchronizer {
+ public static final String LOG_TAG = "TestServLocSync";
+
+ protected Synchronizer getSynchronizer(WBORepository remote, WBORepository local) {
+ BookmarkRecord[] inbounds = new BookmarkRecord[] {
+ new BookmarkRecord("inboundSucc1", "bookmarks", 1, false),
+ new BookmarkRecord("inboundSucc2", "bookmarks", 1, false),
+ new BookmarkRecord("inboundFail1", "bookmarks", 1, false),
+ new BookmarkRecord("inboundSucc3", "bookmarks", 1, false),
+ new BookmarkRecord("inboundFail2", "bookmarks", 1, false),
+ new BookmarkRecord("inboundFail3", "bookmarks", 1, false),
+ };
+ BookmarkRecord[] outbounds = new BookmarkRecord[] {
+ new BookmarkRecord("outboundFail1", "bookmarks", 1, false),
+ new BookmarkRecord("outboundFail2", "bookmarks", 1, false),
+ new BookmarkRecord("outboundFail3", "bookmarks", 1, false),
+ new BookmarkRecord("outboundFail4", "bookmarks", 1, false),
+ new BookmarkRecord("outboundFail5", "bookmarks", 1, false),
+ new BookmarkRecord("outboundFail6", "bookmarks", 1, false),
+ };
+ for (BookmarkRecord inbound : inbounds) {
+ remote.wbos.put(inbound.guid, inbound);
+ }
+ for (BookmarkRecord outbound : outbounds) {
+ local.wbos.put(outbound.guid, outbound);
+ }
+
+ final Synchronizer synchronizer = new ServerLocalSynchronizer();
+ synchronizer.repositoryA = remote;
+ synchronizer.repositoryB = local;
+ return synchronizer;
+ }
+
+ protected static Exception doSynchronize(final Synchronizer synchronizer) {
+ final ArrayList<Exception> a = new ArrayList<Exception>();
+
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ synchronizer.synchronize(null, new SynchronizerDelegate() {
+ @Override
+ public void onSynchronized(Synchronizer synchronizer) {
+ Logger.trace(LOG_TAG, "Got onSynchronized.");
+ a.add(null);
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public void onSynchronizeFailed(Synchronizer synchronizer, Exception lastException, String reason) {
+ Logger.trace(LOG_TAG, "Got onSynchronizedFailed.");
+ a.add(lastException);
+ WaitHelper.getTestWaiter().performNotify();
+ }
+ });
+ }
+ });
+
+ assertEquals(1, a.size()); // Should not be called multiple times!
+ return a.get(0);
+ }
+
+ @Test
+ public void testNoErrors() {
+ WBORepository remote = new TrackingWBORepository();
+ WBORepository local = new TrackingWBORepository();
+
+ Synchronizer synchronizer = getSynchronizer(remote, local);
+ assertNull(doSynchronize(synchronizer));
+
+ assertEquals(12, local.wbos.size());
+ assertEquals(12, remote.wbos.size());
+ }
+
+ @Test
+ public void testLocalFetchErrors() {
+ WBORepository remote = new TrackingWBORepository();
+ WBORepository local = new FailFetchWBORepository();
+
+ Synchronizer synchronizer = getSynchronizer(remote, local);
+ Exception e = doSynchronize(synchronizer);
+ assertNotNull(e);
+ assertEquals(FetchFailedException.class, e.getClass());
+
+ // Neither session gets finished successfully, so all records are dropped.
+ assertEquals(6, local.wbos.size());
+ assertEquals(6, remote.wbos.size());
+ }
+
+ @Test
+ public void testRemoteFetchErrors() {
+ WBORepository remote = new FailFetchWBORepository();
+ WBORepository local = new TrackingWBORepository();
+
+ Synchronizer synchronizer = getSynchronizer(remote, local);
+ Exception e = doSynchronize(synchronizer);
+ assertNotNull(e);
+ assertEquals(FetchFailedException.class, e.getClass());
+
+ // Neither session gets finished successfully, so all records are dropped.
+ assertEquals(6, local.wbos.size());
+ assertEquals(6, remote.wbos.size());
+ }
+
+ @Test
+ public void testLocalSerialStoreErrorsAreIgnored() {
+ WBORepository remote = new TrackingWBORepository();
+ WBORepository local = new SerialFailStoreWBORepository();
+
+ Synchronizer synchronizer = getSynchronizer(remote, local);
+ assertNull(doSynchronize(synchronizer));
+
+ assertEquals(9, local.wbos.size());
+ assertEquals(12, remote.wbos.size());
+ }
+
+ @Test
+ public void testLocalBatchStoreErrorsAreIgnored() {
+ final int BATCH_SIZE = 3;
+
+ Synchronizer synchronizer = getSynchronizer(new TrackingWBORepository(), new BatchFailStoreWBORepository(BATCH_SIZE));
+
+ Exception e = doSynchronize(synchronizer);
+ assertNull(e);
+ }
+
+ @Test
+ public void testRemoteSerialStoreErrorsAreNotIgnored() throws Exception {
+ Synchronizer synchronizer = getSynchronizer(new SerialFailStoreWBORepository(), new TrackingWBORepository()); // Tracking so we don't send incoming records back.
+
+ Exception e = doSynchronize(synchronizer);
+ assertNotNull(e);
+ assertEquals(StoreFailedException.class, e.getClass());
+ }
+
+ @Test
+ public void testRemoteBatchStoreErrorsAreNotIgnoredManyBatches() throws Exception {
+ final int BATCH_SIZE = 3;
+
+ Synchronizer synchronizer = getSynchronizer(new BatchFailStoreWBORepository(BATCH_SIZE), new TrackingWBORepository()); // Tracking so we don't send incoming records back.
+
+ Exception e = doSynchronize(synchronizer);
+ assertNotNull(e);
+ assertEquals(StoreFailedException.class, e.getClass());
+ }
+
+ @Test
+ public void testRemoteBatchStoreErrorsAreNotIgnoredOneBigBatch() throws Exception {
+ final int BATCH_SIZE = 20;
+
+ Synchronizer synchronizer = getSynchronizer(new BatchFailStoreWBORepository(BATCH_SIZE), new TrackingWBORepository()); // Tracking so we don't send incoming records back.
+
+ Exception e = doSynchronize(synchronizer);
+ assertNotNull(e);
+ assertEquals(StoreFailedException.class, e.getClass());
+ }
+
+ @Test
+ public void testSessionRemoteBeginError() {
+ Synchronizer synchronizer = getSynchronizer(new BeginErrorWBORepository(), new TrackingWBORepository());
+ Exception e = doSynchronize(synchronizer);
+ assertNotNull(e);
+ assertEquals(BeginFailedException.class, e.getClass());
+ }
+
+ @Test
+ public void testSessionLocalBeginError() {
+ Synchronizer synchronizer = getSynchronizer(new TrackingWBORepository(), new BeginErrorWBORepository());
+ Exception e = doSynchronize(synchronizer);
+ assertNotNull(e);
+ assertEquals(BeginFailedException.class, e.getClass());
+ }
+
+ @Test
+ public void testSessionRemoteFinishError() {
+ Synchronizer synchronizer = getSynchronizer(new FinishErrorWBORepository(), new TrackingWBORepository());
+ Exception e = doSynchronize(synchronizer);
+ assertNotNull(e);
+ assertEquals(FinishFailedException.class, e.getClass());
+ }
+
+ @Test
+ public void testSessionLocalFinishError() {
+ Synchronizer synchronizer = getSynchronizer(new TrackingWBORepository(), new FinishErrorWBORepository());
+ Exception e = doSynchronize(synchronizer);
+ assertNotNull(e);
+ assertEquals(FinishFailedException.class, e.getClass());
+ }
+
+ @Test
+ public void testSessionBothBeginError() {
+ Synchronizer synchronizer = getSynchronizer(new BeginErrorWBORepository(), new BeginErrorWBORepository());
+ Exception e = doSynchronize(synchronizer);
+ assertNotNull(e);
+ assertEquals(BeginFailedException.class, e.getClass());
+ }
+
+ @Test
+ public void testSessionBothFinishError() {
+ Synchronizer synchronizer = getSynchronizer(new FinishErrorWBORepository(), new FinishErrorWBORepository());
+ Exception e = doSynchronize(synchronizer);
+ assertNotNull(e);
+ assertEquals(FinishFailedException.class, e.getClass());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSyncConfiguration.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSyncConfiguration.java
new file mode 100644
index 000000000..974d799de
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSyncConfiguration.java
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.Sync11Configuration;
+import org.mozilla.gecko.sync.SyncConfiguration;
+
+import java.net.URI;
+
+@RunWith(TestRunner.class)
+public class TestSyncConfiguration {
+ @Test
+ public void testURLs() throws Exception {
+ final MockSharedPreferences prefs = new MockSharedPreferences();
+
+ // N.B., the username isn't used in the cluster path.
+ SyncConfiguration fxaConfig = new SyncConfiguration("username", null, prefs);
+ fxaConfig.clusterURL = new URI("http://db1.oldsync.dev.lcip.org/1.1/174");
+ Assert.assertEquals("http://db1.oldsync.dev.lcip.org/1.1/174/info/collections", fxaConfig.infoCollectionsURL());
+ Assert.assertEquals("http://db1.oldsync.dev.lcip.org/1.1/174/info/collection_counts", fxaConfig.infoCollectionCountsURL());
+ Assert.assertEquals("http://db1.oldsync.dev.lcip.org/1.1/174/storage/meta/global", fxaConfig.metaURL());
+ Assert.assertEquals("http://db1.oldsync.dev.lcip.org/1.1/174/storage", fxaConfig.storageURL());
+ Assert.assertEquals("http://db1.oldsync.dev.lcip.org/1.1/174/storage/collection", fxaConfig.collectionURI("collection").toASCIIString());
+
+ SyncConfiguration oldConfig = new Sync11Configuration("username", null, prefs);
+ oldConfig.clusterURL = new URI("https://db.com/internal/");
+ Assert.assertEquals("https://db.com/internal/1.1/username/info/collections", oldConfig.infoCollectionsURL());
+ Assert.assertEquals("https://db.com/internal/1.1/username/info/collection_counts", oldConfig.infoCollectionCountsURL());
+ Assert.assertEquals("https://db.com/internal/1.1/username/storage/meta/global", oldConfig.metaURL());
+ Assert.assertEquals("https://db.com/internal/1.1/username/storage", oldConfig.storageURL());
+ Assert.assertEquals("https://db.com/internal/1.1/username/storage/collection", oldConfig.collectionURI("collection").toASCIIString());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizer.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizer.java
new file mode 100644
index 000000000..65157beee
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizer.java
@@ -0,0 +1,398 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import android.content.Context;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.SynchronizerHelpers.TrackingWBORepository;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WBORepository;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.synchronizer.Synchronizer;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerDelegate;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerSession;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerSessionDelegate;
+
+import java.util.Date;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+@RunWith(TestRunner.class)
+public class TestSynchronizer {
+ public static final String LOG_TAG = "TestSynchronizer";
+
+ public static void assertInRangeInclusive(long earliest, long value, long latest) {
+ assertTrue(earliest <= value);
+ assertTrue(latest >= value);
+ }
+
+ public static void recordEquals(BookmarkRecord r, String guid, long lastModified, boolean deleted, String collection) {
+ assertEquals(r.guid, guid);
+ assertEquals(r.lastModified, lastModified);
+ assertEquals(r.deleted, deleted);
+ assertEquals(r.collection, collection);
+ }
+
+ public static void recordEquals(BookmarkRecord a, BookmarkRecord b) {
+ assertEquals(a.guid, b.guid);
+ assertEquals(a.lastModified, b.lastModified);
+ assertEquals(a.deleted, b.deleted);
+ assertEquals(a.collection, b.collection);
+ }
+
+ @Before
+ public void setUp() {
+ WaitHelper.resetTestWaiter();
+ }
+
+ @After
+ public void tearDown() {
+ WaitHelper.resetTestWaiter();
+ }
+
+ @Test
+ public void testSynchronizerSession() {
+ final Context context = null;
+ final WBORepository repoA = new TrackingWBORepository();
+ final WBORepository repoB = new TrackingWBORepository();
+
+ final String collection = "bookmarks";
+ final boolean deleted = false;
+ final String guidA = "abcdabcdabcd";
+ final String guidB = "ffffffffffff";
+ final String guidC = "xxxxxxxxxxxx";
+ final long lastModifiedA = 312345;
+ final long lastModifiedB = 412340;
+ final long lastModifiedC = 412345;
+ BookmarkRecord bookmarkRecordA = new BookmarkRecord(guidA, collection, lastModifiedA, deleted);
+ BookmarkRecord bookmarkRecordB = new BookmarkRecord(guidB, collection, lastModifiedB, deleted);
+ BookmarkRecord bookmarkRecordC = new BookmarkRecord(guidC, collection, lastModifiedC, deleted);
+
+ repoA.wbos.put(guidA, bookmarkRecordA);
+ repoB.wbos.put(guidB, bookmarkRecordB);
+ repoB.wbos.put(guidC, bookmarkRecordC);
+ Synchronizer synchronizer = new Synchronizer();
+ synchronizer.repositoryA = repoA;
+ synchronizer.repositoryB = repoB;
+ final SynchronizerSession syncSession = new SynchronizerSession(synchronizer, new SynchronizerSessionDelegate() {
+
+ @Override
+ public void onInitialized(SynchronizerSession session) {
+ assertFalse(repoA.wbos.containsKey(guidB));
+ assertFalse(repoA.wbos.containsKey(guidC));
+ assertFalse(repoB.wbos.containsKey(guidA));
+ assertTrue(repoA.wbos.containsKey(guidA));
+ assertTrue(repoB.wbos.containsKey(guidB));
+ assertTrue(repoB.wbos.containsKey(guidC));
+ session.synchronize();
+ }
+
+ @Override
+ public void onSynchronized(SynchronizerSession session) {
+ try {
+ assertEquals(1, session.getInboundCount());
+ assertEquals(2, session.getOutboundCount());
+ WaitHelper.getTestWaiter().performNotify();
+ } catch (Throwable e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+
+ @Override
+ public void onSynchronizeFailed(SynchronizerSession session,
+ Exception lastException, String reason) {
+ WaitHelper.getTestWaiter().performNotify(lastException);
+ }
+
+ @Override
+ public void onSynchronizeSkipped(SynchronizerSession synchronizerSession) {
+ WaitHelper.getTestWaiter().performNotify(new RuntimeException());
+ }
+ });
+
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ syncSession.init(context, new RepositorySessionBundle(0), new RepositorySessionBundle(0));
+ }
+ });
+
+ // Verify contents.
+ assertTrue(repoA.wbos.containsKey(guidA));
+ assertTrue(repoA.wbos.containsKey(guidB));
+ assertTrue(repoA.wbos.containsKey(guidC));
+ assertTrue(repoB.wbos.containsKey(guidA));
+ assertTrue(repoB.wbos.containsKey(guidB));
+ assertTrue(repoB.wbos.containsKey(guidC));
+ BookmarkRecord aa = (BookmarkRecord) repoA.wbos.get(guidA);
+ BookmarkRecord ab = (BookmarkRecord) repoA.wbos.get(guidB);
+ BookmarkRecord ac = (BookmarkRecord) repoA.wbos.get(guidC);
+ BookmarkRecord ba = (BookmarkRecord) repoB.wbos.get(guidA);
+ BookmarkRecord bb = (BookmarkRecord) repoB.wbos.get(guidB);
+ BookmarkRecord bc = (BookmarkRecord) repoB.wbos.get(guidC);
+ recordEquals(aa, guidA, lastModifiedA, deleted, collection);
+ recordEquals(ab, guidB, lastModifiedB, deleted, collection);
+ recordEquals(ac, guidC, lastModifiedC, deleted, collection);
+ recordEquals(ba, guidA, lastModifiedA, deleted, collection);
+ recordEquals(bb, guidB, lastModifiedB, deleted, collection);
+ recordEquals(bc, guidC, lastModifiedC, deleted, collection);
+ recordEquals(aa, ba);
+ recordEquals(ab, bb);
+ recordEquals(ac, bc);
+ }
+
+ public abstract class SuccessfulSynchronizerDelegate implements SynchronizerDelegate {
+ public long syncAOne = 0;
+ public long syncBOne = 0;
+
+ @Override
+ public void onSynchronizeFailed(Synchronizer synchronizer,
+ Exception lastException, String reason) {
+ fail("Should not fail.");
+ }
+ }
+
+ @Test
+ public void testSynchronizerPersists() {
+ final Object monitor = new Object();
+ final long earliest = new Date().getTime();
+
+ Context context = null;
+ final WBORepository repoA = new WBORepository();
+ final WBORepository repoB = new WBORepository();
+ Synchronizer synchronizer = new Synchronizer();
+ synchronizer.bundleA = new RepositorySessionBundle(0);
+ synchronizer.bundleB = new RepositorySessionBundle(0);
+ synchronizer.repositoryA = repoA;
+ synchronizer.repositoryB = repoB;
+
+ final SuccessfulSynchronizerDelegate delegateOne = new SuccessfulSynchronizerDelegate() {
+ @Override
+ public void onSynchronized(Synchronizer synchronizer) {
+ Logger.trace(LOG_TAG, "onSynchronized. Success!");
+ syncAOne = synchronizer.bundleA.getTimestamp();
+ syncBOne = synchronizer.bundleB.getTimestamp();
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+ };
+ final SuccessfulSynchronizerDelegate delegateTwo = new SuccessfulSynchronizerDelegate() {
+ @Override
+ public void onSynchronized(Synchronizer synchronizer) {
+ Logger.trace(LOG_TAG, "onSynchronized. Success!");
+ syncAOne = synchronizer.bundleA.getTimestamp();
+ syncBOne = synchronizer.bundleB.getTimestamp();
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+ };
+ synchronized (monitor) {
+ synchronizer.synchronize(context, delegateOne);
+ try {
+ monitor.wait();
+ } catch (InterruptedException e) {
+ fail("Interrupted.");
+ }
+ }
+ long now = new Date().getTime();
+ Logger.trace(LOG_TAG, "Earliest is " + earliest);
+ Logger.trace(LOG_TAG, "syncAOne is " + delegateOne.syncAOne);
+ Logger.trace(LOG_TAG, "syncBOne is " + delegateOne.syncBOne);
+ Logger.trace(LOG_TAG, "Now: " + now);
+ assertInRangeInclusive(earliest, delegateOne.syncAOne, now);
+ assertInRangeInclusive(earliest, delegateOne.syncBOne, now);
+ try {
+ Thread.sleep(10);
+ } catch (InterruptedException e) {
+ fail("Thread interrupted!");
+ }
+ synchronized (monitor) {
+ synchronizer.synchronize(context, delegateTwo);
+ try {
+ monitor.wait();
+ } catch (InterruptedException e) {
+ fail("Interrupted.");
+ }
+ }
+ now = new Date().getTime();
+ Logger.trace(LOG_TAG, "Earliest is " + earliest);
+ Logger.trace(LOG_TAG, "syncAOne is " + delegateTwo.syncAOne);
+ Logger.trace(LOG_TAG, "syncBOne is " + delegateTwo.syncBOne);
+ Logger.trace(LOG_TAG, "Now: " + now);
+ assertInRangeInclusive(earliest, delegateTwo.syncAOne, now);
+ assertInRangeInclusive(earliest, delegateTwo.syncBOne, now);
+ assertTrue(delegateTwo.syncAOne > delegateOne.syncAOne);
+ assertTrue(delegateTwo.syncBOne > delegateOne.syncBOne);
+ Logger.trace(LOG_TAG, "Reached end of test.");
+ }
+
+ private Synchronizer getTestSynchronizer(long tsA, long tsB) {
+ WBORepository repoA = new TrackingWBORepository();
+ WBORepository repoB = new TrackingWBORepository();
+ Synchronizer synchronizer = new Synchronizer();
+ synchronizer.bundleA = new RepositorySessionBundle(tsA);
+ synchronizer.bundleB = new RepositorySessionBundle(tsB);
+ synchronizer.repositoryA = repoA;
+ synchronizer.repositoryB = repoB;
+ return synchronizer;
+ }
+
+ /**
+ * Let's put data in two repos and synchronize them with last sync
+ * timestamps later than all of the records. Verify that no records
+ * are exchanged.
+ */
+ @Test
+ public void testSynchronizerFakeTimestamps() {
+ final Context context = null;
+
+ final String collection = "bookmarks";
+ final boolean deleted = false;
+ final String guidA = "abcdabcdabcd";
+ final String guidB = "ffffffffffff";
+ final long lastModifiedA = 312345;
+ final long lastModifiedB = 412345;
+ BookmarkRecord bookmarkRecordA = new BookmarkRecord(guidA, collection, lastModifiedA, deleted);
+ BookmarkRecord bookmarkRecordB = new BookmarkRecord(guidB, collection, lastModifiedB, deleted);
+
+ final Synchronizer synchronizer = getTestSynchronizer(lastModifiedA + 10, lastModifiedB + 10);
+ final WBORepository repoA = (WBORepository) synchronizer.repositoryA;
+ final WBORepository repoB = (WBORepository) synchronizer.repositoryB;
+
+ repoA.wbos.put(guidA, bookmarkRecordA);
+ repoB.wbos.put(guidB, bookmarkRecordB);
+
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ synchronizer.synchronize(context, new SynchronizerDelegate() {
+
+ @Override
+ public void onSynchronized(Synchronizer synchronizer) {
+ try {
+ // No records get sent either way.
+ final SynchronizerSession synchronizerSession = synchronizer.getSynchronizerSession();
+ assertNotNull(synchronizerSession);
+ assertEquals(0, synchronizerSession.getInboundCount());
+ assertEquals(0, synchronizerSession.getOutboundCount());
+ WaitHelper.getTestWaiter().performNotify();
+ } catch (Throwable e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+
+ @Override
+ public void onSynchronizeFailed(Synchronizer synchronizer,
+ Exception lastException, String reason) {
+ WaitHelper.getTestWaiter().performNotify(lastException);
+ }
+ });
+ }
+ });
+
+ // Verify contents.
+ assertTrue(repoA.wbos.containsKey(guidA));
+ assertTrue(repoB.wbos.containsKey(guidB));
+ assertFalse(repoB.wbos.containsKey(guidA));
+ assertFalse(repoA.wbos.containsKey(guidB));
+ BookmarkRecord aa = (BookmarkRecord) repoA.wbos.get(guidA);
+ BookmarkRecord ab = (BookmarkRecord) repoA.wbos.get(guidB);
+ BookmarkRecord ba = (BookmarkRecord) repoB.wbos.get(guidA);
+ BookmarkRecord bb = (BookmarkRecord) repoB.wbos.get(guidB);
+ assertNull(ab);
+ assertNull(ba);
+ recordEquals(aa, guidA, lastModifiedA, deleted, collection);
+ recordEquals(bb, guidB, lastModifiedB, deleted, collection);
+ }
+
+
+ @Test
+ public void testSynchronizer() {
+ final Context context = null;
+
+ final String collection = "bookmarks";
+ final boolean deleted = false;
+ final String guidA = "abcdabcdabcd";
+ final String guidB = "ffffffffffff";
+ final String guidC = "gggggggggggg";
+ final long lastModifiedA = 312345;
+ final long lastModifiedB = 412340;
+ final long lastModifiedC = 412345;
+ BookmarkRecord bookmarkRecordA = new BookmarkRecord(guidA, collection, lastModifiedA, deleted);
+ BookmarkRecord bookmarkRecordB = new BookmarkRecord(guidB, collection, lastModifiedB, deleted);
+ BookmarkRecord bookmarkRecordC = new BookmarkRecord(guidC, collection, lastModifiedC, deleted);
+
+ final Synchronizer synchronizer = getTestSynchronizer(0, 0);
+ final WBORepository repoA = (WBORepository) synchronizer.repositoryA;
+ final WBORepository repoB = (WBORepository) synchronizer.repositoryB;
+
+ repoA.wbos.put(guidA, bookmarkRecordA);
+ repoB.wbos.put(guidB, bookmarkRecordB);
+ repoB.wbos.put(guidC, bookmarkRecordC);
+
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ synchronizer.synchronize(context, new SynchronizerDelegate() {
+
+ @Override
+ public void onSynchronized(Synchronizer synchronizer) {
+ try {
+ // No records get sent either way.
+ final SynchronizerSession synchronizerSession = synchronizer.getSynchronizerSession();
+ assertNotNull(synchronizerSession);
+ assertEquals(1, synchronizerSession.getInboundCount());
+ assertEquals(2, synchronizerSession.getOutboundCount());
+ WaitHelper.getTestWaiter().performNotify();
+ } catch (Throwable e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+
+ @Override
+ public void onSynchronizeFailed(Synchronizer synchronizer,
+ Exception lastException, String reason) {
+ WaitHelper.getTestWaiter().performNotify(lastException);
+ }
+ });
+ }
+ });
+
+ // Verify contents.
+ assertTrue(repoA.wbos.containsKey(guidA));
+ assertTrue(repoA.wbos.containsKey(guidB));
+ assertTrue(repoA.wbos.containsKey(guidC));
+ assertTrue(repoB.wbos.containsKey(guidA));
+ assertTrue(repoB.wbos.containsKey(guidB));
+ assertTrue(repoB.wbos.containsKey(guidC));
+ BookmarkRecord aa = (BookmarkRecord) repoA.wbos.get(guidA);
+ BookmarkRecord ab = (BookmarkRecord) repoA.wbos.get(guidB);
+ BookmarkRecord ac = (BookmarkRecord) repoA.wbos.get(guidC);
+ BookmarkRecord ba = (BookmarkRecord) repoB.wbos.get(guidA);
+ BookmarkRecord bb = (BookmarkRecord) repoB.wbos.get(guidB);
+ BookmarkRecord bc = (BookmarkRecord) repoB.wbos.get(guidC);
+ recordEquals(aa, guidA, lastModifiedA, deleted, collection);
+ recordEquals(ab, guidB, lastModifiedB, deleted, collection);
+ recordEquals(ac, guidC, lastModifiedC, deleted, collection);
+ recordEquals(ba, guidA, lastModifiedA, deleted, collection);
+ recordEquals(bb, guidB, lastModifiedB, deleted, collection);
+ recordEquals(bc, guidC, lastModifiedC, deleted, collection);
+ recordEquals(aa, ba);
+ recordEquals(ab, bb);
+ recordEquals(ac, bc);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizerSession.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizerSession.java
new file mode 100644
index 000000000..ddc3ae68e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizerSession.java
@@ -0,0 +1,306 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import android.content.Context;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.SynchronizerHelpers.DataAvailableWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.ShouldSkipWBORepository;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WBORepository;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.SynchronizerConfiguration;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+import org.mozilla.gecko.sync.synchronizer.Synchronizer;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerSession;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerSessionDelegate;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+@RunWith(TestRunner.class)
+public class TestSynchronizerSession {
+ public static final String LOG_TAG = TestSynchronizerSession.class.getSimpleName();
+
+ protected static void assertFirstContainsSecond(Map<String, Record> first, Map<String, Record> second) {
+ for (Entry<String, Record> entry : second.entrySet()) {
+ assertTrue("Expected key " + entry.getKey(), first.containsKey(entry.getKey()));
+ Record record = first.get(entry.getKey());
+ assertEquals(entry.getValue(), record);
+ }
+ }
+
+ protected static void assertFirstDoesNotContainSecond(Map<String, Record> first, Map<String, Record> second) {
+ for (Entry<String, Record> entry : second.entrySet()) {
+ assertFalse("Unexpected key " + entry.getKey(), first.containsKey(entry.getKey()));
+ }
+ }
+
+ protected WBORepository repoA = null;
+ protected WBORepository repoB = null;
+ protected SynchronizerSession syncSession = null;
+ protected Map<String, Record> originalWbosA = null;
+ protected Map<String, Record> originalWbosB = null;
+
+ @Before
+ public void setUp() {
+ repoA = new DataAvailableWBORepository(false);
+ repoB = new DataAvailableWBORepository(false);
+
+ final String collection = "bookmarks";
+ final boolean deleted = false;
+ final String guidA = "abcdabcdabcd";
+ final String guidB = "ffffffffffff";
+ final String guidC = "xxxxxxxxxxxx";
+ final long lastModifiedA = 312345;
+ final long lastModifiedB = 412340;
+ final long lastModifiedC = 412345;
+ final BookmarkRecord bookmarkRecordA = new BookmarkRecord(guidA, collection, lastModifiedA, deleted);
+ final BookmarkRecord bookmarkRecordB = new BookmarkRecord(guidB, collection, lastModifiedB, deleted);
+ final BookmarkRecord bookmarkRecordC = new BookmarkRecord(guidC, collection, lastModifiedC, deleted);
+
+ repoA.wbos.put(guidA, bookmarkRecordA);
+ repoB.wbos.put(guidB, bookmarkRecordB);
+ repoB.wbos.put(guidC, bookmarkRecordC);
+
+ originalWbosA = new HashMap<String, Record>(repoA.wbos);
+ originalWbosB = new HashMap<String, Record>(repoB.wbos);
+
+ Synchronizer synchronizer = new Synchronizer();
+ synchronizer.repositoryA = repoA;
+ synchronizer.repositoryB = repoB;
+ syncSession = new SynchronizerSession(synchronizer, new SynchronizerSessionDelegate() {
+ @Override
+ public void onInitialized(SynchronizerSession session) {
+ session.synchronize();
+ }
+
+ @Override
+ public void onSynchronized(SynchronizerSession session) {
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public void onSynchronizeFailed(SynchronizerSession session, Exception lastException, String reason) {
+ WaitHelper.getTestWaiter().performNotify(lastException);
+ }
+
+ @Override
+ public void onSynchronizeSkipped(SynchronizerSession synchronizerSession) {
+ WaitHelper.getTestWaiter().performNotify(new RuntimeException("Not expecting onSynchronizeSkipped"));
+ }
+ });
+ }
+
+ protected void logStats() {
+ // Uncomment this line to print stats to console:
+ // Logger.startLoggingTo(new PrintLogWriter(new PrintWriter(System.out, true)));
+
+ Logger.debug(LOG_TAG, "Repo A fetch done: " + repoA.stats.fetchCompleted);
+ Logger.debug(LOG_TAG, "Repo B store done: " + repoB.stats.storeCompleted);
+ Logger.debug(LOG_TAG, "Repo B fetch done: " + repoB.stats.fetchCompleted);
+ Logger.debug(LOG_TAG, "Repo A store done: " + repoA.stats.storeCompleted);
+
+ SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+ Logger.debug(LOG_TAG, "Repo A timestamp: " + sc.remoteBundle.getTimestamp());
+ Logger.debug(LOG_TAG, "Repo B timestamp: " + sc.localBundle.getTimestamp());
+ }
+
+ protected void doTest(boolean remoteDataAvailable, boolean localDataAvailable) {
+ ((DataAvailableWBORepository) repoA).dataAvailable = remoteDataAvailable;
+ ((DataAvailableWBORepository) repoB).dataAvailable = localDataAvailable;
+
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ final Context context = null;
+ syncSession.init(context,
+ new RepositorySessionBundle(0),
+ new RepositorySessionBundle(0));
+ }
+ });
+
+ logStats();
+ }
+
+ @Test
+ public void testSynchronizerSessionBothHaveData() {
+ long before = System.currentTimeMillis();
+ boolean remoteDataAvailable = true;
+ boolean localDataAvailable = true;
+ doTest(remoteDataAvailable, localDataAvailable);
+ long after = System.currentTimeMillis();
+
+ assertEquals(1, syncSession.getInboundCount());
+ assertEquals(2, syncSession.getOutboundCount());
+
+ // Didn't lose any records.
+ assertFirstContainsSecond(repoA.wbos, originalWbosA);
+ assertFirstContainsSecond(repoB.wbos, originalWbosB);
+ // Got new records.
+ assertFirstContainsSecond(repoA.wbos, originalWbosB);
+ assertFirstContainsSecond(repoB.wbos, originalWbosA);
+
+ // Timestamps updated.
+ SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+ TestSynchronizer.assertInRangeInclusive(before, sc.localBundle.getTimestamp(), after);
+ TestSynchronizer.assertInRangeInclusive(before, sc.remoteBundle.getTimestamp(), after);
+ }
+
+ @Test
+ public void testSynchronizerSessionOnlyLocalHasData() {
+ long before = System.currentTimeMillis();
+ boolean remoteDataAvailable = false;
+ boolean localDataAvailable = true;
+ doTest(remoteDataAvailable, localDataAvailable);
+ long after = System.currentTimeMillis();
+
+ // Record counts updated.
+ assertEquals(0, syncSession.getInboundCount());
+ assertEquals(2, syncSession.getOutboundCount());
+
+ // Didn't lose any records.
+ assertFirstContainsSecond(repoA.wbos, originalWbosA);
+ assertFirstContainsSecond(repoB.wbos, originalWbosB);
+ // Got new records.
+ assertFirstContainsSecond(repoA.wbos, originalWbosB);
+ // Didn't get records we shouldn't have fetched.
+ assertFirstDoesNotContainSecond(repoB.wbos, originalWbosA);
+
+ // Timestamps updated.
+ SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+ TestSynchronizer.assertInRangeInclusive(before, sc.localBundle.getTimestamp(), after);
+ TestSynchronizer.assertInRangeInclusive(before, sc.remoteBundle.getTimestamp(), after);
+ }
+
+ @Test
+ public void testSynchronizerSessionOnlyRemoteHasData() {
+ long before = System.currentTimeMillis();
+ boolean remoteDataAvailable = true;
+ boolean localDataAvailable = false;
+ doTest(remoteDataAvailable, localDataAvailable);
+ long after = System.currentTimeMillis();
+
+ // Record counts updated.
+ assertEquals(1, syncSession.getInboundCount());
+ assertEquals(0, syncSession.getOutboundCount());
+
+ // Didn't lose any records.
+ assertFirstContainsSecond(repoA.wbos, originalWbosA);
+ assertFirstContainsSecond(repoB.wbos, originalWbosB);
+ // Got new records.
+ assertFirstContainsSecond(repoB.wbos, originalWbosA);
+ // Didn't get records we shouldn't have fetched.
+ assertFirstDoesNotContainSecond(repoA.wbos, originalWbosB);
+
+ // Timestamps updated.
+ SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+ TestSynchronizer.assertInRangeInclusive(before, sc.localBundle.getTimestamp(), after);
+ TestSynchronizer.assertInRangeInclusive(before, sc.remoteBundle.getTimestamp(), after);
+ }
+
+ @Test
+ public void testSynchronizerSessionNeitherHaveData() {
+ long before = System.currentTimeMillis();
+ boolean remoteDataAvailable = false;
+ boolean localDataAvailable = false;
+ doTest(remoteDataAvailable, localDataAvailable);
+ long after = System.currentTimeMillis();
+
+ // Record counts updated.
+ assertEquals(0, syncSession.getInboundCount());
+ assertEquals(0, syncSession.getOutboundCount());
+
+ // Didn't lose any records.
+ assertFirstContainsSecond(repoA.wbos, originalWbosA);
+ assertFirstContainsSecond(repoB.wbos, originalWbosB);
+ // Didn't get records we shouldn't have fetched.
+ assertFirstDoesNotContainSecond(repoA.wbos, originalWbosB);
+ assertFirstDoesNotContainSecond(repoB.wbos, originalWbosA);
+
+ // Timestamps updated.
+ SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+ TestSynchronizer.assertInRangeInclusive(before, sc.localBundle.getTimestamp(), after);
+ TestSynchronizer.assertInRangeInclusive(before, sc.remoteBundle.getTimestamp(), after);
+ }
+
+ protected void doSkipTest(boolean remoteShouldSkip, boolean localShouldSkip) {
+ repoA = new ShouldSkipWBORepository(remoteShouldSkip);
+ repoB = new ShouldSkipWBORepository(localShouldSkip);
+
+ Synchronizer synchronizer = new Synchronizer();
+ synchronizer.repositoryA = repoA;
+ synchronizer.repositoryB = repoB;
+
+ syncSession = new SynchronizerSession(synchronizer, new SynchronizerSessionDelegate() {
+ @Override
+ public void onInitialized(SynchronizerSession session) {
+ session.synchronize();
+ }
+
+ @Override
+ public void onSynchronized(SynchronizerSession session) {
+ WaitHelper.getTestWaiter().performNotify(new RuntimeException("Not expecting onSynchronized"));
+ }
+
+ @Override
+ public void onSynchronizeFailed(SynchronizerSession session, Exception lastException, String reason) {
+ WaitHelper.getTestWaiter().performNotify(lastException);
+ }
+
+ @Override
+ public void onSynchronizeSkipped(SynchronizerSession synchronizerSession) {
+ WaitHelper.getTestWaiter().performNotify();
+ }
+ });
+
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ final Context context = null;
+ syncSession.init(context,
+ new RepositorySessionBundle(100),
+ new RepositorySessionBundle(200));
+ }
+ });
+
+ // If we skip, we don't update timestamps or even un-bundle.
+ SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+ assertNotNull(sc);
+ assertNull(sc.localBundle);
+ assertNull(sc.remoteBundle);
+ assertEquals(-1, syncSession.getInboundCount());
+ assertEquals(-1, syncSession.getOutboundCount());
+ }
+
+ @Test
+ public void testSynchronizerSessionShouldSkip() {
+ // These combinations should all skip.
+ doSkipTest(true, false);
+
+ doSkipTest(false, true);
+ doSkipTest(true, true);
+
+ try {
+ doSkipTest(false, false);
+ fail("Expected exception.");
+ } catch (WaitHelper.InnerError e) {
+ assertTrue(e.innerError instanceof RuntimeException);
+ assertEquals("Not expecting onSynchronized", e.innerError.getMessage());
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestUtils.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestUtils.java
new file mode 100644
index 000000000..bc9a99dae
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestUtils.java
@@ -0,0 +1,154 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.SyncConstants;
+import org.mozilla.gecko.sync.Utils;
+
+import java.io.UnsupportedEncodingException;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestUtils extends Utils {
+
+ @Test
+ public void testGenerateGUID() {
+ for (int i = 0; i < 1000; ++i) {
+ assertEquals(12, Utils.generateGuid().length());
+ }
+ }
+
+ public static final byte[][] BYTE_ARRS = {
+ new byte[] {' '}, // Tab.
+ new byte[] {'0'},
+ new byte[] {'A'},
+ new byte[] {'a'},
+ new byte[] {'I', 'U'},
+ new byte[] {'`', 'h', 'g', ' ', 's', '`'},
+ new byte[] {}
+ };
+ // Indices correspond with the above array.
+ public static final String[] STRING_ARR = {
+ "09",
+ "30",
+ "41",
+ "61",
+ "4955",
+ "606867207360",
+ ""
+ };
+
+ @Test
+ public void testByte2Hex() throws Exception {
+ for (int i = 0; i < BYTE_ARRS.length; ++i) {
+ final byte[] b = BYTE_ARRS[i];
+ final String expected = STRING_ARR[i];
+ assertEquals(expected, Utils.byte2Hex(b));
+ }
+ }
+
+ @Test
+ public void testHex2Byte() throws Exception {
+ for (int i = 0; i < STRING_ARR.length; ++i) {
+ final String s = STRING_ARR[i];
+ final byte[] expected = BYTE_ARRS[i];
+ assertTrue(Arrays.equals(expected, Utils.hex2Byte(s)));
+ }
+ }
+
+ @Test
+ public void testByte2Hex2ByteAndViceVersa() throws Exception { // There and back again!
+ for (int i = 0; i < BYTE_ARRS.length; ++i) {
+ // byte2Hex2Byte
+ final byte[] b = BYTE_ARRS[i];
+ final String s = Utils.byte2Hex(b);
+ assertTrue(Arrays.equals(b, Utils.hex2Byte(s)));
+ }
+
+ // hex2Byte2Hex
+ for (int i = 0; i < STRING_ARR.length; ++i) {
+ final String s = STRING_ARR[i];
+ final byte[] b = Utils.hex2Byte(s);
+ assertEquals(s, Utils.byte2Hex(b));
+ }
+ }
+
+ @Test
+ public void testByte2HexLength() throws Exception {
+ for (int i = 0; i < BYTE_ARRS.length; ++i) {
+ final byte[] b = BYTE_ARRS[i];
+ final String expected = STRING_ARR[i];
+ assertEquals(expected, Utils.byte2Hex(b, b.length));
+ assertEquals("0" + expected, Utils.byte2Hex(b, 2 * b.length + 1));
+ assertEquals("00" + expected, Utils.byte2Hex(b, 2 * b.length + 2));
+ }
+ }
+
+ @Test
+ public void testHex2ByteLength() throws Exception {
+ for (int i = 0; i < STRING_ARR.length; ++i) {
+ final String s = STRING_ARR[i];
+ final byte[] expected = BYTE_ARRS[i];
+ assertTrue(Arrays.equals(expected, Utils.hex2Byte(s)));
+ final byte[] expected1 = new byte[expected.length + 1];
+ System.arraycopy(expected, 0, expected1, 1, expected.length);
+ assertTrue(Arrays.equals(expected1, Utils.hex2Byte("00" + s)));
+ final byte[] expected2 = new byte[expected.length + 2];
+ System.arraycopy(expected, 0, expected2, 2, expected.length);
+ assertTrue(Arrays.equals(expected2, Utils.hex2Byte("0000" + s)));
+ }
+ }
+
+ @Test
+ public void testToCommaSeparatedString() {
+ ArrayList<String> xs = new ArrayList<String>();
+ assertEquals("", Utils.toCommaSeparatedString(null));
+ assertEquals("", Utils.toCommaSeparatedString(xs));
+ xs.add("test1");
+ assertEquals("test1", Utils.toCommaSeparatedString(xs));
+ xs.add("test2");
+ assertEquals("test1, test2", Utils.toCommaSeparatedString(xs));
+ xs.add("test3");
+ assertEquals("test1, test2, test3", Utils.toCommaSeparatedString(xs));
+ }
+
+ @Test
+ public void testUsernameFromAccount() throws NoSuchAlgorithmException, UnsupportedEncodingException {
+ assertEquals("xee7ffonluzpdp66l6xgpyh2v2w6ojkc", Utils.sha1Base32("foobar@baz.com"));
+ assertEquals("xee7ffonluzpdp66l6xgpyh2v2w6ojkc", Utils.usernameFromAccount("foobar@baz.com"));
+ assertEquals("xee7ffonluzpdp66l6xgpyh2v2w6ojkc", Utils.usernameFromAccount("FooBar@Baz.com"));
+ assertEquals("xee7ffonluzpdp66l6xgpyh2v2w6ojkc", Utils.usernameFromAccount("xee7ffonluzpdp66l6xgpyh2v2w6ojkc"));
+ assertEquals("foobar", Utils.usernameFromAccount("foobar"));
+ assertEquals("foobar", Utils.usernameFromAccount("FOOBAr"));
+ }
+
+ @Test
+ public void testGetPrefsPath() throws NoSuchAlgorithmException, UnsupportedEncodingException {
+ assertEquals("ore7dlrwqi6xr7honxdtpvmh6tly4r7k", Utils.sha1Base32("test.url.com:xee7ffonluzpdp66l6xgpyh2v2w6ojkc"));
+
+ assertEquals("sync.prefs.ore7dlrwqi6xr7honxdtpvmh6tly4r7k", Utils.getPrefsPath("product", "foobar@baz.com", "test.url.com", "default", 0));
+ assertEquals("sync.prefs.ore7dlrwqi6xr7honxdtpvmh6tly4r7k", Utils.getPrefsPath("org.mozilla.firefox_beta", "FooBar@Baz.com", "test.url.com", "default", 0));
+ assertEquals("sync.prefs.ore7dlrwqi6xr7honxdtpvmh6tly4r7k", Utils.getPrefsPath("org.mozilla.firefox", "xee7ffonluzpdp66l6xgpyh2v2w6ojkc", "test.url.com", "profile", 0));
+
+ assertEquals("sync.prefs.product.ore7dlrwqi6xr7honxdtpvmh6tly4r7k.default.1", Utils.getPrefsPath("product", "foobar@baz.com", "test.url.com", "default", 1));
+ assertEquals("sync.prefs.with!spaces_underbars!periods.ore7dlrwqi6xr7honxdtpvmh6tly4r7k.default.1", Utils.getPrefsPath("with spaces_underbars.periods", "foobar@baz.com", "test.url.com", "default", 1));
+ assertEquals("sync.prefs.org!mozilla!firefox_beta.ore7dlrwqi6xr7honxdtpvmh6tly4r7k.default.2", Utils.getPrefsPath("org.mozilla.firefox_beta", "FooBar@Baz.com", "test.url.com", "default", 2));
+ assertEquals("sync.prefs.org!mozilla!firefox.ore7dlrwqi6xr7honxdtpvmh6tly4r7k.profile.3", Utils.getPrefsPath("org.mozilla.firefox", "xee7ffonluzpdp66l6xgpyh2v2w6ojkc", "test.url.com", "profile", 3));
+ }
+
+ @Test
+ public void testObfuscateEmail() {
+ assertEquals("XXX@XXX.XXX", Utils.obfuscateEmail("foo@bar.com"));
+ assertEquals("XXXX@XXX.XXXX.XX", Utils.obfuscateEmail("foot@bar.test.ca"));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/BaseTestStorageRequestDelegate.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/BaseTestStorageRequestDelegate.java
new file mode 100644
index 000000000..a87925608
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/BaseTestStorageRequestDelegate.java
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+import java.io.IOException;
+
+import static org.junit.Assert.fail;
+
+public class BaseTestStorageRequestDelegate implements
+ SyncStorageRequestDelegate {
+
+ protected final AuthHeaderProvider authHeaderProvider;
+
+ public BaseTestStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+ this.authHeaderProvider = authHeaderProvider;
+ }
+
+ public BaseTestStorageRequestDelegate(String username, String password) {
+ this(new BasicAuthHeaderProvider(username, password));
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return authHeaderProvider;
+ }
+
+ @Override
+ public String ifUnmodifiedSince() {
+ return null;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ BaseResource.consumeEntity(response);
+ fail("Should not be called.");
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ System.out.println("Response: " + response.httpResponse().getStatusLine().getStatusCode());
+ BaseResource.consumeEntity(response);
+ fail("Should not be called.");
+ }
+
+ @Override
+ public void handleRequestError(Exception e) {
+ if (e instanceof IOException) {
+ System.out.println("WARNING: TEST FAILURE IGNORED!");
+ // Assume that this is because Jenkins doesn't have network access.
+ return;
+ }
+ fail("Should not error.");
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessDelegate.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessDelegate.java
new file mode 100644
index 000000000..cf3545c1e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessDelegate.java
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+
+public class ExpectSuccessDelegate {
+ public WaitHelper waitHelper;
+
+ public ExpectSuccessDelegate(WaitHelper waitHelper) {
+ this.waitHelper = waitHelper;
+ }
+
+ public void performNotify() {
+ this.waitHelper.performNotify();
+ }
+
+ public void performNotify(Throwable e) {
+ this.waitHelper.performNotify(e);
+ }
+
+ public String logTag() {
+ return this.getClass().getSimpleName();
+ }
+
+ public void log(String message) {
+ Logger.info(logTag(), message);
+ }
+
+ public void log(String message, Throwable throwable) {
+ Logger.warn(logTag(), message, throwable);
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionBeginDelegate.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionBeginDelegate.java
new file mode 100644
index 000000000..d7cb186f8
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionBeginDelegate.java
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import junit.framework.AssertionFailedError;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+
+import java.util.concurrent.ExecutorService;
+
+public class ExpectSuccessRepositorySessionBeginDelegate
+extends ExpectSuccessDelegate
+implements RepositorySessionBeginDelegate {
+
+ public ExpectSuccessRepositorySessionBeginDelegate(WaitHelper waitHelper) {
+ super(waitHelper);
+ }
+
+ @Override
+ public void onBeginFailed(Exception ex) {
+ log("Session begin failed.", ex);
+ performNotify(new AssertionFailedError("Session begin failed: " + ex.getMessage()));
+ }
+
+ @Override
+ public void onBeginSucceeded(RepositorySession session) {
+ log("Session begin succeeded.");
+ performNotify();
+ }
+
+ @Override
+ public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor) {
+ log("Session begin delegate deferred.");
+ return this;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionCreationDelegate.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionCreationDelegate.java
new file mode 100644
index 000000000..8860baf77
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionCreationDelegate.java
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import junit.framework.AssertionFailedError;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+
+public class ExpectSuccessRepositorySessionCreationDelegate extends
+ ExpectSuccessDelegate implements RepositorySessionCreationDelegate {
+
+ public ExpectSuccessRepositorySessionCreationDelegate(WaitHelper waitHelper) {
+ super(waitHelper);
+ }
+
+ @Override
+ public void onSessionCreateFailed(Exception ex) {
+ log("Session creation failed.", ex);
+ performNotify(new AssertionFailedError("onSessionCreateFailed: session creation should not have failed."));
+ }
+
+ @Override
+ public void onSessionCreated(RepositorySession session) {
+ log("Session creation succeeded.");
+ performNotify();
+ }
+
+ @Override
+ public RepositorySessionCreationDelegate deferredCreationDelegate() {
+ log("Session creation deferred.");
+ return this;
+ }
+
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFetchRecordsDelegate.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFetchRecordsDelegate.java
new file mode 100644
index 000000000..5f5cf8995
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFetchRecordsDelegate.java
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import junit.framework.AssertionFailedError;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import java.util.ArrayList;
+import java.util.concurrent.ExecutorService;
+
+public class ExpectSuccessRepositorySessionFetchRecordsDelegate extends
+ ExpectSuccessDelegate implements RepositorySessionFetchRecordsDelegate {
+ public ArrayList<Record> fetchedRecords = new ArrayList<Record>();
+
+ public ExpectSuccessRepositorySessionFetchRecordsDelegate(WaitHelper waitHelper) {
+ super(waitHelper);
+ }
+
+ @Override
+ public void onFetchFailed(Exception ex, Record record) {
+ log("Fetch failed.", ex);
+ performNotify(new AssertionFailedError("onFetchFailed: fetch should not have failed."));
+ }
+
+ @Override
+ public void onFetchedRecord(Record record) {
+ fetchedRecords.add(record);
+ log("Fetched record with guid '" + record.guid + "'.");
+ }
+
+ @Override
+ public void onFetchCompleted(long end) {
+ log("Fetch completed.");
+ performNotify();
+ }
+
+ @Override
+ public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor) {
+ return this;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFinishDelegate.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFinishDelegate.java
new file mode 100644
index 000000000..4435d5fa2
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFinishDelegate.java
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import junit.framework.AssertionFailedError;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+
+import java.util.concurrent.ExecutorService;
+
+public class ExpectSuccessRepositorySessionFinishDelegate extends
+ ExpectSuccessDelegate implements RepositorySessionFinishDelegate {
+
+ public ExpectSuccessRepositorySessionFinishDelegate(WaitHelper waitHelper) {
+ super(waitHelper);
+ }
+
+ @Override
+ public void onFinishFailed(Exception ex) {
+ log("Finish failed.", ex);
+ performNotify(new AssertionFailedError("onFinishFailed: finish should not have failed."));
+ }
+
+ @Override
+ public void onFinishSucceeded(RepositorySession session, RepositorySessionBundle bundle) {
+ log("Finish succeeded.");
+ performNotify();
+ }
+
+ @Override
+ public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService executor) {
+ return this;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionStoreDelegate.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionStoreDelegate.java
new file mode 100644
index 000000000..cfca180fa
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionStoreDelegate.java
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import junit.framework.AssertionFailedError;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
+
+import java.util.concurrent.ExecutorService;
+
+public class ExpectSuccessRepositorySessionStoreDelegate extends
+ ExpectSuccessDelegate implements RepositorySessionStoreDelegate {
+
+ public ExpectSuccessRepositorySessionStoreDelegate(WaitHelper waitHelper) {
+ super(waitHelper);
+ }
+
+ @Override
+ public void onRecordStoreFailed(Exception ex, String guid) {
+ log("Record store failed.", ex);
+ performNotify(new AssertionFailedError("onRecordStoreFailed: record store should not have failed."));
+ }
+
+ @Override
+ public void onRecordStoreSucceeded(String guid) {
+ log("Record store succeeded.");
+ }
+
+ @Override
+ public void onStoreCompleted(long storeEnd) {
+ log("Record store completed at " + storeEnd);
+ }
+
+ @Override
+ public RepositorySessionStoreDelegate deferredStoreDelegate(ExecutorService executor) {
+ return this;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositoryWipeDelegate.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositoryWipeDelegate.java
new file mode 100644
index 000000000..0f248dda7
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositoryWipeDelegate.java
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import junit.framework.AssertionFailedError;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
+
+import java.util.concurrent.ExecutorService;
+
+public class ExpectSuccessRepositoryWipeDelegate extends ExpectSuccessDelegate
+ implements RepositorySessionWipeDelegate {
+
+ public ExpectSuccessRepositoryWipeDelegate(WaitHelper waitHelper) {
+ super(waitHelper);
+ }
+
+ @Override
+ public void onWipeSucceeded() {
+ log("Wipe succeeded.");
+ performNotify();
+ }
+
+ @Override
+ public void onWipeFailed(Exception ex) {
+ log("Wipe failed.", ex);
+ performNotify(new AssertionFailedError("onWipeFailed: wipe should not have failed."));
+ }
+
+ @Override
+ public RepositorySessionWipeDelegate deferredWipeDelegate(ExecutorService executor) {
+ log("Wipe deferred.");
+ return this;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/HTTPServerTestHelper.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/HTTPServerTestHelper.java
new file mode 100644
index 000000000..1829bdd12
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/HTTPServerTestHelper.java
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BaseResourceDelegate;
+import org.simpleframework.http.core.ContainerSocketProcessor;
+import org.simpleframework.transport.connect.Connection;
+import org.simpleframework.transport.connect.SocketConnection;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.fail;
+
+/**
+ * Test helper code to bind <code>MockServer</code> instances to ports.
+ * <p>
+ * Maintains a collection of running servers and (by default) throws helpful
+ * errors if two servers are started "on top" of each other. The
+ * <b>unchecked</b> exception thrown contains a stack trace pointing to where
+ * the new server is being created and where the pre-existing server was
+ * created.
+ * <p>
+ * Parses a system property to determine current test port, which is fixed for
+ * the duration of a test execution.
+ */
+public class HTTPServerTestHelper {
+ private static final String LOG_TAG = "HTTPServerTestHelper";
+
+ /**
+ * Port to run HTTP servers on during this test execution.
+ * <p>
+ * Lazily initialized on first call to {@link #getTestPort}.
+ */
+ public static Integer testPort = null;
+
+ public static final String LOCAL_HTTP_PORT_PROPERTY = "android.sync.local.http.port";
+ public static final int LOCAL_HTTP_PORT_DEFAULT = 15125;
+
+ public final int port;
+
+ public Connection connection;
+ public MockServer server;
+
+ /**
+ * Create a helper to bind <code>MockServer</code> instances.
+ * <p>
+ * Use {@link #getTestPort} to determine the port this helper will bind to.
+ */
+ public HTTPServerTestHelper() {
+ this.port = getTestPort();
+ }
+
+ // For testing only.
+ protected HTTPServerTestHelper(int port) {
+ this.port = port;
+ }
+
+ /**
+ * Lazily initialize test port for this test execution.
+ * <p>
+ * Only called from {@link #getTestPort}.
+ * <p>
+ * If the test port has not been determined, we try to parse it from a system
+ * property; if that fails, we return the default test port.
+ */
+ protected synchronized static void ensureTestPort() {
+ if (testPort != null) {
+ return;
+ }
+
+ String value = System.getProperty(LOCAL_HTTP_PORT_PROPERTY);
+ if (value != null) {
+ try {
+ testPort = Integer.valueOf(value);
+ } catch (NumberFormatException e) {
+ Logger.warn(LOG_TAG, "Got exception parsing local test port; ignoring. ", e);
+ }
+ }
+
+ if (testPort == null) {
+ testPort = Integer.valueOf(LOCAL_HTTP_PORT_DEFAULT);
+ }
+ }
+
+ /**
+ * The port to which all HTTP servers will be found for the duration of this
+ * test execution.
+ * <p>
+ * We try to parse the port from a system property; if that fails, we return
+ * the default test port.
+ *
+ * @return port number.
+ */
+ public synchronized static int getTestPort() {
+ if (testPort == null) {
+ ensureTestPort();
+ }
+
+ return testPort.intValue();
+ }
+
+ /**
+ * Used to maintain a stack trace pointing to where a server was started.
+ */
+ public static class HTTPServerStartedError extends Error {
+ private static final long serialVersionUID = -6778447718799087274L;
+
+ public final HTTPServerTestHelper httpServer;
+
+ public HTTPServerStartedError(HTTPServerTestHelper httpServer) {
+ this.httpServer = httpServer;
+ }
+ }
+
+ /**
+ * Thrown when a server is started "on top" of another server. The cause error
+ * will be an <code>HTTPServerStartedError</code> with a stack trace pointing
+ * to where the pre-existing server was started.
+ */
+ public static class HTTPServerAlreadyRunningError extends Error {
+ private static final long serialVersionUID = -6778447718799087275L;
+
+ public HTTPServerAlreadyRunningError(Throwable e) {
+ super(e);
+ }
+ }
+
+ /**
+ * Maintain a hash of running servers. Each value is an error with a stack
+ * traces pointing to where that server was started.
+ * <p>
+ * We don't key on the server itself because each server is a <it>helper</it>
+ * that may be started many times with different <code>MockServer</code>
+ * instances.
+ * <p>
+ * Synchronize access on the class.
+ */
+ protected static Map<Connection, HTTPServerStartedError> runningServers =
+ new IdentityHashMap<Connection, HTTPServerStartedError>();
+
+ protected synchronized static void throwIfServerAlreadyRunning() {
+ for (HTTPServerStartedError value : runningServers.values()) {
+ throw new HTTPServerAlreadyRunningError(value);
+ }
+ }
+
+ protected synchronized static void registerServerAsRunning(HTTPServerTestHelper httpServer) {
+ if (httpServer == null || httpServer.connection == null) {
+ throw new IllegalArgumentException("HTTPServerTestHelper or connection was null; perhaps server has not been started?");
+ }
+
+ HTTPServerStartedError old = runningServers.put(httpServer.connection, new HTTPServerStartedError(httpServer));
+ if (old != null) {
+ // Should never happen.
+ throw old;
+ }
+ }
+
+ protected synchronized static void unregisterServerAsRunning(HTTPServerTestHelper httpServer) {
+ if (httpServer == null || httpServer.connection == null) {
+ throw new IllegalArgumentException("HTTPServerTestHelper or connection was null; perhaps server has not been started?");
+ }
+
+ runningServers.remove(httpServer.connection);
+ }
+
+ public MockServer startHTTPServer(MockServer server, boolean allowMultipleServers) {
+ BaseResource.rewriteLocalhost = false; // No sense rewriting when we're running the unit tests.
+ BaseResourceDelegate.connectionTimeoutInMillis = 1000; // No sense waiting a long time for a local connection.
+
+ if (!allowMultipleServers) {
+ throwIfServerAlreadyRunning();
+ }
+
+ try {
+ this.server = server;
+ connection = new SocketConnection(new ContainerSocketProcessor(server));
+ SocketAddress address = new InetSocketAddress(port);
+ connection.connect(address);
+
+ registerServerAsRunning(this);
+
+ Logger.info(LOG_TAG, "Started HTTP server on port " + port + ".");
+ } catch (IOException ex) {
+ Logger.error(LOG_TAG, "Error starting HTTP server on port " + port + ".", ex);
+ fail(ex.toString());
+ }
+
+ return server;
+ }
+
+ public MockServer startHTTPServer(MockServer server) {
+ return startHTTPServer(server, false);
+ }
+
+ public MockServer startHTTPServer() {
+ return startHTTPServer(new MockServer());
+ }
+
+ public void stopHTTPServer() {
+ try {
+ if (connection != null) {
+ unregisterServerAsRunning(this);
+
+ connection.close();
+ }
+ server = null;
+ connection = null;
+
+ Logger.info(LOG_TAG, "Stopped HTTP server on port " + port + ".");
+
+ Logger.debug(LOG_TAG, "Closing connection pool...");
+ BaseResource.shutdownConnectionManager();
+ } catch (IOException ex) {
+ Logger.error(LOG_TAG, "Error stopping HTTP server on port " + port + ".", ex);
+ fail(ex.toString());
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockGlobalSessionCallback.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockGlobalSessionCallback.java
new file mode 100644
index 000000000..5d7e8edd1
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockGlobalSessionCallback.java
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+
+import java.net.URI;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * A callback for use with a GlobalSession that records what happens for later
+ * inspection.
+ *
+ * This callback is expected to be used from within the friendly confines of a
+ * WaitHelper performWait.
+ */
+public class MockGlobalSessionCallback implements GlobalSessionCallback {
+ protected WaitHelper testWaiter() {
+ return WaitHelper.getTestWaiter();
+ }
+
+ public int stageCounter = Stage.values().length - 1; // Exclude starting state.
+ public boolean calledSuccess = false;
+ public boolean calledError = false;
+ public Exception calledErrorException = null;
+ public boolean calledAborted = false;
+ public boolean calledRequestBackoff = false;
+ public boolean calledInformUnauthorizedResponse = false;
+ public boolean calledInformUpgradeRequiredResponse = false;
+ public boolean calledInformMigrated = false;
+ public URI calledInformUnauthorizedResponseClusterURL = null;
+ public long weaveBackoff = -1;
+
+ @Override
+ public void handleSuccess(GlobalSession globalSession) {
+ this.calledSuccess = true;
+ assertEquals(0, this.stageCounter);
+ this.testWaiter().performNotify();
+ }
+
+ @Override
+ public void handleAborted(GlobalSession globalSession, String reason) {
+ this.calledAborted = true;
+ this.testWaiter().performNotify();
+ }
+
+ @Override
+ public void handleError(GlobalSession globalSession, Exception ex) {
+ this.calledError = true;
+ this.calledErrorException = ex;
+ this.testWaiter().performNotify();
+ }
+
+ @Override
+ public void handleStageCompleted(Stage currentState,
+ GlobalSession globalSession) {
+ stageCounter--;
+ }
+
+ @Override
+ public void requestBackoff(long backoff) {
+ this.calledRequestBackoff = true;
+ this.weaveBackoff = backoff;
+ }
+
+ @Override
+ public void informUnauthorizedResponse(GlobalSession session, URI clusterURL) {
+ this.calledInformUnauthorizedResponse = true;
+ this.calledInformUnauthorizedResponseClusterURL = clusterURL;
+ }
+
+ @Override
+ public void informUpgradeRequiredResponse(GlobalSession session) {
+ this.calledInformUpgradeRequiredResponse = true;
+ }
+
+ @Override
+ public void informMigrated(GlobalSession session) {
+ this.calledInformMigrated = true;
+ }
+
+ @Override
+ public boolean shouldBackOffStorage() {
+ return false;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockResourceDelegate.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockResourceDelegate.java
new file mode 100644
index 000000000..2cac07904
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockResourceDelegate.java
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+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 org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.ResourceDelegate;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+import static org.junit.Assert.assertEquals;
+
+public class MockResourceDelegate implements ResourceDelegate {
+ public WaitHelper waitHelper = null;
+ public static String USER_PASS = "john:password";
+ public static String EXPECT_BASIC = "Basic am9objpwYXNzd29yZA==";
+
+ public boolean handledHttpResponse = false;
+ public HttpResponse httpResponse = null;
+
+ public MockResourceDelegate(WaitHelper waitHelper) {
+ this.waitHelper = waitHelper;
+ }
+
+ public MockResourceDelegate() {
+ this.waitHelper = WaitHelper.getTestWaiter();
+ }
+
+ @Override
+ public String getUserAgent() {
+ return null;
+ }
+
+ @Override
+ public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+ }
+
+ @Override
+ public int connectionTimeout() {
+ return 0;
+ }
+
+ @Override
+ public int socketTimeout() {
+ return 0;
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return new BasicAuthHeaderProvider(USER_PASS);
+ }
+
+ @Override
+ public void handleHttpProtocolException(ClientProtocolException e) {
+ waitHelper.performNotify(e);
+ }
+
+ @Override
+ public void handleHttpIOException(IOException e) {
+ waitHelper.performNotify(e);
+ }
+
+ @Override
+ public void handleTransportException(GeneralSecurityException e) {
+ waitHelper.performNotify(e);
+ }
+
+ @Override
+ public void handleHttpResponse(HttpResponse response) {
+ handledHttpResponse = true;
+ httpResponse = response;
+
+ assertEquals(response.getStatusLine().getStatusCode(), 200);
+ BaseResource.consumeEntity(response);
+ waitHelper.performNotify();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockServer.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockServer.java
new file mode 100644
index 000000000..4e1d6d7ad
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockServer.java
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.Utils;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+import org.simpleframework.http.core.Container;
+
+import java.io.IOException;
+import java.io.PrintStream;
+
+import static org.junit.Assert.assertEquals;
+
+public class MockServer implements Container {
+ public static final String LOG_TAG = "MockServer";
+
+ public int statusCode = 200;
+ public String body = "Hello World";
+
+ public MockServer() {
+ }
+
+ public MockServer(int statusCode, String body) {
+ this.statusCode = statusCode;
+ this.body = body;
+ }
+
+ public String expectedBasicAuthHeader;
+
+ protected PrintStream handleBasicHeaders(Request request, Response response, int code, String contentType) throws IOException {
+ return this.handleBasicHeaders(request, response, code, contentType, System.currentTimeMillis());
+ }
+
+ protected PrintStream handleBasicHeaders(Request request, Response response, int code, String contentType, long time) throws IOException {
+ Logger.debug(LOG_TAG, "< Auth header: " + request.getValue("Authorization"));
+
+ PrintStream bodyStream = response.getPrintStream();
+ response.setCode(code);
+ response.setValue("Content-Type", contentType);
+ response.setValue("Server", "HelloWorld/1.0 (Simple 4.0)");
+ response.setDate("Date", time);
+ response.setDate("Last-Modified", time);
+
+ final String timestampHeader = Utils.millisecondsToDecimalSecondsString(time);
+ response.setValue("X-Weave-Timestamp", timestampHeader);
+ Logger.debug(LOG_TAG, "> X-Weave-Timestamp header: " + timestampHeader);
+ response.setValue("X-Last-Modified", "12345678");
+ return bodyStream;
+ }
+
+ protected void handle(Request request, Response response, int code, String body) {
+ try {
+ Logger.debug(LOG_TAG, "Handling request...");
+ PrintStream bodyStream = this.handleBasicHeaders(request, response, code, "application/json");
+
+ if (expectedBasicAuthHeader != null) {
+ Logger.debug(LOG_TAG, "Expecting auth header " + expectedBasicAuthHeader);
+ assertEquals(request.getValue("Authorization"), expectedBasicAuthHeader);
+ }
+
+ bodyStream.println(body);
+ bodyStream.close();
+ } catch (IOException e) {
+ Logger.error(LOG_TAG, "Oops.");
+ }
+ }
+ public void handle(Request request, Response response) {
+ this.handle(request, response, statusCode, body);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockSyncClientsEngineStage.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockSyncClientsEngineStage.java
new file mode 100644
index 000000000..efd379e13
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockSyncClientsEngineStage.java
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.stage.SyncClientsEngineStage;
+
+import static org.junit.Assert.assertTrue;
+
+public class MockSyncClientsEngineStage extends SyncClientsEngineStage {
+ public class MockClientUploadDelegate extends ClientUploadDelegate {
+ HTTPServerTestHelper data;
+
+ public MockClientUploadDelegate(HTTPServerTestHelper data) {
+ this.data = data;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ assertTrue(response.wasSuccessful());
+ data.stopHTTPServer();
+ super.handleRequestSuccess(response);
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ BaseResource.consumeEntity(response);
+ data.stopHTTPServer();
+ super.handleRequestFailure(response);
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ ex.printStackTrace();
+ data.stopHTTPServer();
+ super.handleRequestError(ex);
+ }
+ }
+
+ public class TestClientDownloadDelegate extends ClientDownloadDelegate {
+ HTTPServerTestHelper data;
+
+ public TestClientDownloadDelegate(HTTPServerTestHelper data) {
+ this.data = data;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ assertTrue(response.wasSuccessful());
+ BaseResource.consumeEntity(response);
+ data.stopHTTPServer();
+ super.handleRequestSuccess(response);
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ BaseResource.consumeEntity(response);
+ super.handleRequestFailure(response);
+ data.stopHTTPServer();
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ ex.printStackTrace();
+ super.handleRequestError(ex);
+ data.stopHTTPServer();
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockWBOServer.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockWBOServer.java
new file mode 100644
index 000000000..164274bac
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockWBOServer.java
@@ -0,0 +1,28 @@
+package org.mozilla.android.sync.test.helpers;
+
+import org.simpleframework.http.Path;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import java.util.HashMap;
+
+/**
+ * A trivial server that collects and returns WBOs.
+ *
+ * @author rnewman
+ *
+ */
+public class MockWBOServer extends MockServer {
+ public HashMap<String, HashMap<String, String> > collections;
+
+ public MockWBOServer() {
+ collections = new HashMap<String, HashMap<String, String> >();
+ }
+
+ @Override
+ public void handle(Request request, Response response) {
+ Path path = request.getPath();
+ path.getPath(0);
+ // TODO
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/test/TestHTTPServerTestHelper.java b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/test/TestHTTPServerTestHelper.java
new file mode 100644
index 000000000..d79998cc9
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/test/TestHTTPServerTestHelper.java
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper.HTTPServerAlreadyRunningError;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+@RunWith(TestRunner.class)
+public class TestHTTPServerTestHelper {
+ public static final int TEST_PORT = HTTPServerTestHelper.getTestPort();
+
+ protected MockServer mockServer = new MockServer();
+
+ @Test
+ public void testStartStop() {
+ // Need to be able to start and stop multiple times.
+ for (int i = 0; i < 2; i++) {
+ HTTPServerTestHelper httpServer = new HTTPServerTestHelper();
+
+ assertNull(httpServer.connection);
+ httpServer.startHTTPServer(mockServer);
+
+ assertNotNull(httpServer.connection);
+ httpServer.stopHTTPServer();
+ }
+ }
+
+ public void startAgain() {
+ HTTPServerTestHelper httpServer = new HTTPServerTestHelper();
+ httpServer.startHTTPServer(mockServer);
+ }
+
+ @Test
+ public void testStartTwice() {
+ HTTPServerTestHelper httpServer = new HTTPServerTestHelper();
+
+ httpServer.startHTTPServer(mockServer);
+ assertNotNull(httpServer.connection);
+
+ // Should not be able to start multiple times.
+ try {
+ try {
+ startAgain();
+
+ fail("Expected exception.");
+ } catch (Throwable e) {
+ assertEquals(HTTPServerAlreadyRunningError.class, e.getClass());
+
+ StringWriter sw = new StringWriter();
+ e.printStackTrace(new PrintWriter(sw));
+ String s = sw.toString();
+
+ // Ensure we get a useful stack trace.
+ // We should have the method trying to start the server the second time...
+ assertTrue(s.contains("startAgain"));
+ // ... as well as the the method that started the server the first time.
+ assertTrue(s.contains("testStartTwice"));
+ }
+ } finally {
+ httpServer.stopHTTPServer();
+ }
+ }
+
+ protected static class LeakyHTTPServerTestHelper extends HTTPServerTestHelper {
+ // Make this constructor public, just for this test.
+ public LeakyHTTPServerTestHelper(int port) {
+ super(port);
+ }
+ }
+
+ @Test
+ public void testForceStartTwice() {
+ HTTPServerTestHelper httpServer1 = new HTTPServerTestHelper();
+ HTTPServerTestHelper httpServer2 = new LeakyHTTPServerTestHelper(httpServer1.port + 1);
+
+ // Should be able to start multiple times if we specify it.
+ try {
+ httpServer1.startHTTPServer(mockServer);
+ assertNotNull(httpServer1.connection);
+
+ httpServer2.startHTTPServer(mockServer, true);
+ assertNotNull(httpServer2.connection);
+ } finally {
+ httpServer1.stopHTTPServer();
+ httpServer2.stopHTTPServer();
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/GeckoNetworkManagerTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/GeckoNetworkManagerTest.java
new file mode 100644
index 000000000..8b07d7448
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/GeckoNetworkManagerTest.java
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.GeckoNetworkManager.ManagerState;
+import org.mozilla.gecko.GeckoNetworkManager.ManagerEvent;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class GeckoNetworkManagerTest {
+ /**
+ * Tests the transition matrix.
+ */
+ @Test
+ public void testGetNextState() {
+ ManagerState testingState;
+
+ testingState = ManagerState.OffNoListeners;
+ assertNull(GeckoNetworkManager.getNextState(testingState, ManagerEvent.disableNotifications));
+ assertNull(GeckoNetworkManager.getNextState(testingState, ManagerEvent.stop));
+ assertNull(GeckoNetworkManager.getNextState(testingState, ManagerEvent.receivedUpdate));
+ assertEquals(ManagerState.OnNoListeners, GeckoNetworkManager.getNextState(testingState, ManagerEvent.start));
+ assertEquals(ManagerState.OffWithListeners, GeckoNetworkManager.getNextState(testingState, ManagerEvent.enableNotifications));
+
+ testingState = ManagerState.OnNoListeners;
+ assertNull(GeckoNetworkManager.getNextState(testingState, ManagerEvent.start));
+ assertNull(GeckoNetworkManager.getNextState(testingState, ManagerEvent.disableNotifications));
+ assertEquals(ManagerState.OnWithListeners, GeckoNetworkManager.getNextState(testingState, ManagerEvent.enableNotifications));
+ assertEquals(ManagerState.OffNoListeners, GeckoNetworkManager.getNextState(testingState, ManagerEvent.stop));
+ assertEquals(ManagerState.OnNoListeners, GeckoNetworkManager.getNextState(testingState, ManagerEvent.receivedUpdate));
+
+ testingState = ManagerState.OnWithListeners;
+ assertNull(GeckoNetworkManager.getNextState(testingState, ManagerEvent.start));
+ assertNull(GeckoNetworkManager.getNextState(testingState, ManagerEvent.enableNotifications));
+ assertEquals(ManagerState.OffWithListeners, GeckoNetworkManager.getNextState(testingState, ManagerEvent.stop));
+ assertEquals(ManagerState.OnNoListeners, GeckoNetworkManager.getNextState(testingState, ManagerEvent.disableNotifications));
+ assertEquals(ManagerState.OnWithListeners, GeckoNetworkManager.getNextState(testingState, ManagerEvent.receivedUpdate));
+
+ testingState = ManagerState.OffWithListeners;
+ assertNull(GeckoNetworkManager.getNextState(testingState, ManagerEvent.stop));
+ assertNull(GeckoNetworkManager.getNextState(testingState, ManagerEvent.enableNotifications));
+ assertNull(GeckoNetworkManager.getNextState(testingState, ManagerEvent.receivedUpdate));
+ assertEquals(ManagerState.OnWithListeners, GeckoNetworkManager.getNextState(testingState, ManagerEvent.start));
+ assertEquals(ManagerState.OffNoListeners, GeckoNetworkManager.getNextState(testingState, ManagerEvent.disableNotifications));
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/GlobalPageMetadataTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/GlobalPageMetadataTest.java
new file mode 100644
index 000000000..f30ee7a2c
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/GlobalPageMetadataTest.java
@@ -0,0 +1,174 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko;
+
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.os.RemoteException;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.db.DelegatingTestContentProvider;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.PageMetadata;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.BrowserProvider;
+import org.mozilla.gecko.db.LocalBrowserDB;
+import org.robolectric.shadows.ShadowContentResolver;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class GlobalPageMetadataTest {
+ @Test
+ public void testQueueing() throws Exception {
+ BrowserDB db = new LocalBrowserDB("default");
+
+ BrowserProvider provider = new BrowserProvider();
+ try {
+ provider.onCreate();
+ ShadowContentResolver.registerProvider(BrowserContract.AUTHORITY, new DelegatingTestContentProvider(provider));
+
+ ShadowContentResolver cr = new ShadowContentResolver();
+ ContentProviderClient pageMetadataClient = cr.acquireContentProviderClient(PageMetadata.CONTENT_URI);
+
+ assertEquals(0, GlobalPageMetadata.getInstance().getMetadataQueueSize());
+
+ // There's not history record for this uri, so test that queueing works.
+ GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org", false, "{type: 'article'}");
+
+ assertPageMetadataCountForGUID(0, "guid1", pageMetadataClient);
+ assertEquals(1, GlobalPageMetadata.getInstance().getMetadataQueueSize());
+
+ // Test that queue doesn't duplicate metadata for the same history item.
+ GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org", false, "{type: 'article'}");
+ assertEquals(1, GlobalPageMetadata.getInstance().getMetadataQueueSize());
+
+ // Test that queue is limited to 15 metadata items.
+ for (int i = 0; i < 20; i++) {
+ GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org/" + i, false, "{type: 'article'}");
+ }
+ assertEquals(15, GlobalPageMetadata.getInstance().getMetadataQueueSize());
+ } finally {
+ provider.shutdown();
+ }
+ }
+
+ @Test
+ public void testInsertingMetadata() throws Exception {
+ BrowserDB db = new LocalBrowserDB("default");
+
+ // Start listening for events.
+ GlobalPageMetadata.getInstance().init();
+
+ BrowserProvider provider = new BrowserProvider();
+ try {
+ provider.onCreate();
+ ShadowContentResolver.registerProvider(BrowserContract.AUTHORITY, new DelegatingTestContentProvider(provider));
+
+ ShadowContentResolver cr = new ShadowContentResolver();
+ ContentProviderClient historyClient = cr.acquireContentProviderClient(BrowserContract.History.CONTENT_URI);
+ ContentProviderClient pageMetadataClient = cr.acquireContentProviderClient(PageMetadata.CONTENT_URI);
+
+ // Insert required history item...
+ ContentValues cv = new ContentValues();
+ cv.put(BrowserContract.History.GUID, "guid1");
+ cv.put(BrowserContract.History.URL, "https://mozilla.org");
+ historyClient.insert(BrowserContract.History.CONTENT_URI, cv);
+
+ // TODO: Main test runner thread finishes before EventDispatcher events are processed...
+ // Fire off a message saying that history has been inserted.
+ // Bundle message = new Bundle();
+ // message.putString(GlobalHistory.EVENT_PARAM_URI, "https://mozilla.org");
+ // EventDispatcher.getInstance().dispatch(GlobalHistory.EVENT_URI_AVAILABLE_IN_HISTORY, message);
+
+ // For now, let's just try inserting again.
+ GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org", false, "{type: 'article', description: 'test article'}");
+
+ assertPageMetadataCountForGUID(1, "guid1", pageMetadataClient);
+ assertPageMetadataValues(pageMetadataClient, "guid1", false, "{\"type\":\"article\",\"description\":\"test article\"}");
+
+ // Test that inserting empty metadata deletes existing metadata record.
+ GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org", false, "{}");
+ assertPageMetadataCountForGUID(0, "guid1", pageMetadataClient);
+
+ // Test that inserting new metadata overrides existing metadata record.
+ GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://mozilla.org", true, "{type: 'article', description: 'test article', image_url: 'https://example.com/test.png'}");
+ assertPageMetadataValues(pageMetadataClient, "guid1", true, "{\"type\":\"article\",\"description\":\"test article\",\"image_url\":\"https:\\/\\/example.com\\/test.png\"}");
+
+ // Insert another history item...
+ cv = new ContentValues();
+ cv.put(BrowserContract.History.GUID, "guid2");
+ cv.put(BrowserContract.History.URL, "https://planet.mozilla.org");
+ historyClient.insert(BrowserContract.History.CONTENT_URI, cv);
+ // Test that empty metadata doesn't get inserted for a new history.
+ GlobalPageMetadata.getInstance().doAddOrQueue(db, pageMetadataClient, "https://planet.mozilla.org", false, "{}");
+
+ assertPageMetadataCountForGUID(0, "guid2", pageMetadataClient);
+
+ } finally {
+ provider.shutdown();
+ }
+ }
+
+ /**
+ * Expects cursor to be at the correct position.
+ */
+ private void assertCursorValues(Cursor cursor, String json, int hasImage, String guid) {
+ assertNotNull(cursor);
+ assertEquals(json, cursor.getString(cursor.getColumnIndexOrThrow(PageMetadata.JSON)));
+ assertEquals(hasImage, cursor.getInt(cursor.getColumnIndexOrThrow(PageMetadata.HAS_IMAGE)));
+ assertEquals(guid, cursor.getString(cursor.getColumnIndexOrThrow(PageMetadata.HISTORY_GUID)));
+ }
+
+ private void assertPageMetadataValues(ContentProviderClient client, String guid, boolean hasImage, String json) {
+ final Cursor cursor;
+
+ try {
+ cursor = client.query(PageMetadata.CONTENT_URI, new String[]{
+ PageMetadata.HISTORY_GUID,
+ PageMetadata.HAS_IMAGE,
+ PageMetadata.JSON,
+ PageMetadata.DATE_CREATED
+ }, PageMetadata.HISTORY_GUID + " = ?", new String[]{guid}, null);
+ } catch (RemoteException e) {
+ fail();
+ return;
+ }
+
+ assertNotNull(cursor);
+ try {
+ assertEquals(1, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+ assertCursorValues(cursor, json, hasImage ? 1 : 0, guid);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ private void assertPageMetadataCountForGUID(int expected, String guid, ContentProviderClient client) {
+ final Cursor cursor;
+
+ try {
+ cursor = client.query(PageMetadata.CONTENT_URI, new String[]{
+ PageMetadata.HISTORY_GUID,
+ PageMetadata.HAS_IMAGE,
+ PageMetadata.JSON,
+ PageMetadata.DATE_CREATED
+ }, PageMetadata.HISTORY_GUID + " = ?", new String[]{guid}, null);
+ } catch (RemoteException e) {
+ fail();
+ return;
+ }
+
+ assertNotNull(cursor);
+ try {
+ assertEquals(expected, cursor.getCount());
+ } finally {
+ cursor.close();
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/TestGeckoProfile.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/TestGeckoProfile.java
new file mode 100644
index 000000000..c01c2d21a
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/TestGeckoProfile.java
@@ -0,0 +1,254 @@
+/* -*- 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;
+
+import android.content.Context;
+
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.util.FileUtils;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit test methods of the GeckoProfile class.
+ */
+@RunWith(TestRunner.class)
+public class TestGeckoProfile {
+ private static final String PROFILE_NAME = "profileName";
+
+ private static final String CLIENT_ID_JSON_ATTR = "clientID";
+ private static final String PROFILE_CREATION_DATE_JSON_ATTR = "created";
+
+ @Rule
+ public TemporaryFolder dirContainingProfile = new TemporaryFolder();
+
+ private File profileDir;
+ private GeckoProfile profile;
+
+ private File clientIdFile;
+ private File timesFile;
+
+ @Before
+ public void setUp() throws IOException {
+ final Context context = RuntimeEnvironment.application;
+ profileDir = dirContainingProfile.newFolder();
+ profile = GeckoProfile.get(context, PROFILE_NAME, profileDir);
+
+ clientIdFile = new File(profileDir, "datareporting/state.json");
+ timesFile = new File(profileDir, "times.json");
+ }
+
+ public void assertValidClientId(final String clientId) {
+ // This isn't the method we use in the main GeckoProfile code, but it should be equivalent.
+ UUID.fromString(clientId); // assert: will throw if null or invalid UUID.
+ }
+
+ @Test
+ public void testGetDir() {
+ assertEquals("Profile dir argument during construction and returned value are equal",
+ profileDir, profile.getDir());
+ }
+
+ @Test
+ public void testGetClientIdFreshProfile() throws Exception {
+ assertFalse("client ID file does not exist", clientIdFile.exists());
+
+ // No existing client ID file: we're expected to create one.
+ final String clientId = profile.getClientId();
+ assertValidClientId(clientId);
+ assertTrue("client ID file exists", clientIdFile.exists());
+
+ assertEquals("Returned client ID is the same as the one previously returned", clientId, profile.getClientId());
+ assertEquals("clientID file format matches expectations", clientId, readClientIdFromFile(clientIdFile));
+ }
+
+ @Test
+ public void testGetClientIdFileAlreadyExists() throws Exception {
+ final String validClientId = "905de1c0-0ea6-4a43-95f9-6170035f5a82";
+ assertTrue("Created the parent dirs of the client ID file", clientIdFile.getParentFile().mkdirs());
+ writeClientIdToFile(clientIdFile, validClientId);
+
+ final String clientIdFromProfile = profile.getClientId();
+ assertEquals("Client ID from method matches ID written to disk", validClientId, clientIdFromProfile);
+ }
+
+ @Test
+ public void testGetClientIdMigrateFromFHR() throws Exception {
+ final File fhrClientIdFile = new File(profileDir, "healthreport/state.json");
+ final String fhrClientId = "905de1c0-0ea6-4a43-95f9-6170035f5a82";
+
+ assertFalse("client ID file does not exist", clientIdFile.exists());
+ assertTrue("Created FHR data directory", new File(profileDir, "healthreport").mkdirs());
+ writeClientIdToFile(fhrClientIdFile, fhrClientId);
+ assertEquals("Migrated Client ID equals FHR client ID", fhrClientId, profile.getClientId());
+
+ // Verify migration wrote to contemporary client ID file.
+ assertTrue("Client ID file created during migration", clientIdFile.exists());
+ assertEquals("Migrated client ID on disk equals value returned from method",
+ fhrClientId, readClientIdFromFile(clientIdFile));
+
+ assertTrue("Deleted FHR clientID file", fhrClientIdFile.delete());
+ assertEquals("Ensure method calls read from newly created client ID file & not FHR client ID file",
+ fhrClientId, profile.getClientId());
+ }
+
+ @Test
+ public void testGetClientIdInvalidIdOnDisk() throws Exception {
+ assertTrue("Created the parent dirs of the client ID file", clientIdFile.getParentFile().mkdirs());
+ writeClientIdToFile(clientIdFile, "");
+ final String clientIdForEmptyString = profile.getClientId();
+ assertValidClientId(clientIdForEmptyString);
+ assertNotEquals("A new client ID was created when the empty String was written to disk", "", clientIdForEmptyString);
+
+ writeClientIdToFile(clientIdFile, "invalidClientId");
+ final String clientIdForInvalidClientId = profile.getClientId();
+ assertValidClientId(clientIdForInvalidClientId);
+ assertNotEquals("A new client ID was created when an invalid client ID was written to disk",
+ "invalidClientId", clientIdForInvalidClientId);
+ }
+
+ @Test
+ public void testGetClientIdMissingClientIdJSONAttr() throws Exception {
+ final String validClientId = "905de1c0-0ea6-4a43-95f9-6170035f5a82";
+ final JSONObject objMissingClientId = new JSONObject();
+ objMissingClientId.put("irrelevantKey", validClientId);
+ assertTrue("Created the parent dirs of the client ID file", clientIdFile.getParentFile().mkdirs());
+ FileUtils.writeJSONObjectToFile(clientIdFile, objMissingClientId);
+
+ final String clientIdForMissingAttr = profile.getClientId();
+ assertValidClientId(clientIdForMissingAttr);
+ assertNotEquals("Did not use other attr when JSON attr was missing", validClientId, clientIdForMissingAttr);
+ }
+
+ @Test
+ public void testGetClientIdInvalidIdFileFormat() throws Exception {
+ final String validClientId = "905de1c0-0ea6-4a43-95f9-6170035f5a82";
+ assertTrue("Created the parent dirs of the client ID file", clientIdFile.getParentFile().mkdirs());
+ FileUtils.writeStringToFile(clientIdFile, "clientID: \"" + validClientId + "\"");
+
+ final String clientIdForInvalidFormat = profile.getClientId();
+ assertValidClientId(clientIdForInvalidFormat);
+ assertNotEquals("Created new ID when file format was invalid", validClientId, clientIdForInvalidFormat);
+ }
+
+ @Test
+ public void testEnsureParentDirs() {
+ final File grandParentDir = new File(profileDir, "grandParent");
+ final File parentDir = new File(grandParentDir, "parent");
+ final File childFile = new File(parentDir, "child");
+
+ // Assert initial state.
+ assertFalse("Topmost parent dir should not exist yet", grandParentDir.exists());
+ assertFalse("Bottommost parent dir should not exist yet", parentDir.exists());
+ assertFalse("Child file should not exist", childFile.exists());
+
+ final String fakeFullPath = "grandParent/parent/child";
+ assertTrue("Parent directories should be created", profile.ensureParentDirs(fakeFullPath));
+ assertTrue("Topmost parent dir should have been created", grandParentDir.exists());
+ assertTrue("Bottommost parent dir should have been created", parentDir.exists());
+ assertFalse("Child file should not have been created", childFile.exists());
+
+ // Parents already exist because this is the second time we're calling ensureParentDirs.
+ assertTrue("Expect true if parent directories already exist", profile.ensureParentDirs(fakeFullPath));
+
+ // Assert error condition.
+ assertTrue("Ensure we can change permissions on profile dir for testing", profileDir.setReadOnly());
+ assertFalse("Expect false if the parent dir could not be created", profile.ensureParentDirs("unwritableDir/child"));
+ }
+
+ @Test
+ public void testIsClientIdValid() {
+ final String[] validClientIds = new String[] {
+ "905de1c0-0ea6-4a43-95f9-6170035f5a82",
+ "905de1c0-0ea6-4a43-95f9-6170035f5a83",
+ "57472f82-453d-4c55-b59c-d3c0e97b76a1",
+ "895745d1-f31e-46c3-880e-b4dd72963d4f",
+ };
+ for (final String validClientId : validClientIds) {
+ assertTrue("Client ID, " + validClientId + ", is valid", profile.isClientIdValid(validClientId));
+ }
+
+ final String[] invalidClientIds = new String[] {
+ null,
+ "",
+ "a",
+ "anInvalidClientId",
+ "905de1c0-0ea6-4a43-95f9-6170035f5a820", // too long (last section)
+ "905de1c0-0ea6-4a43-95f9-6170035f5a8", // too short (last section)
+ "05de1c0-0ea6-4a43-95f9-6170035f5a82", // too short (first section)
+ "905de1c0-0ea6-4a43-95f9-6170035f5a8!", // contains a symbol
+ };
+ for (final String invalidClientId : invalidClientIds) {
+ assertFalse("Client ID, " + invalidClientId + ", is invalid", profile.isClientIdValid(invalidClientId));
+ }
+
+ // We generate client IDs using UUID - better make sure they're valid.
+ for (int i = 0; i < 30; ++i) {
+ final String generatedClientId = UUID.randomUUID().toString();
+ assertTrue("Generated client ID from UUID, " + generatedClientId + ", is valid",
+ profile.isClientIdValid(generatedClientId));
+ }
+ }
+
+ @Test
+ public void testGetProfileCreationDateFromTimesFile() throws Exception {
+ final long expectedDate = System.currentTimeMillis();
+ final JSONObject expectedObj = new JSONObject();
+ expectedObj.put(PROFILE_CREATION_DATE_JSON_ATTR, expectedDate);
+ FileUtils.writeJSONObjectToFile(timesFile, expectedObj);
+
+ final Context context = RuntimeEnvironment.application;
+ final long actualDate = profile.getAndPersistProfileCreationDate(context);
+ assertEquals("Date from disk equals date inserted to disk", expectedDate, actualDate);
+
+ final long actualDateFromDisk = readProfileCreationDateFromFile(timesFile);
+ assertEquals("Date in times.json has not changed after accessing profile creation date",
+ expectedDate, actualDateFromDisk);
+ }
+
+ @Test
+ public void testGetProfileCreationDateTimesFileDoesNotExist() throws Exception {
+ assertFalse("Times.json does not already exist", timesFile.exists());
+
+ final Context context = RuntimeEnvironment.application;
+ final long actualDate = profile.getAndPersistProfileCreationDate(context);
+ // I'd prefer to mock so we can return and verify a specific value but we can't mock
+ // GeckoProfile because it's final. Instead, we check if the value is at least reasonable.
+ assertTrue("Date from method is positive", actualDate >= 0);
+ assertTrue("Date from method is less than current time", actualDate < System.currentTimeMillis());
+
+ assertTrue("Times.json exists after getting profile", timesFile.exists());
+ final long actualDateFromDisk = readProfileCreationDateFromFile(timesFile);
+ assertEquals("Date from disk equals returned value", actualDate, actualDateFromDisk);
+ }
+
+ private static long readProfileCreationDateFromFile(final File file) throws Exception {
+ final JSONObject actualObj = FileUtils.readJSONObjectFromFile(file);
+ return actualObj.getLong(PROFILE_CREATION_DATE_JSON_ATTR);
+ }
+
+ private String readClientIdFromFile(final File file) throws Exception {
+ final JSONObject obj = FileUtils.readJSONObjectFromFile(file);
+ return obj.getString(CLIENT_ID_JSON_ATTR);
+ }
+
+ private void writeClientIdToFile(final File file, final String clientId) throws Exception {
+ final JSONObject obj = new JSONObject();
+ obj.put(CLIENT_ID_JSON_ATTR, clientId);
+ FileUtils.writeJSONObjectToFile(file, obj);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/activitystream/TestActivityStream.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/activitystream/TestActivityStream.java
new file mode 100644
index 000000000..71f01b437
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/activitystream/TestActivityStream.java
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.activitystream;
+
+import android.os.SystemClock;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.shadows.ShadowLooper;
+
+import static org.junit.Assert.assertEquals;
+
+@RunWith(TestRunner.class)
+public class TestActivityStream {
+ /**
+ * Unit tests for ActivityStream.extractLabel().
+ *
+ * Most test cases are based on this list:
+ * https://gist.github.com/nchapman/36502ad115e8825d522a66549971a3f0
+ */
+ @Test
+ public void testExtractLabelWithPath() {
+ // Empty values
+ assertLabelEquals("", "", true);
+ assertLabelEquals("", null, true);
+
+ // Without path
+ assertLabelEquals("news.ycombinator", "https://news.ycombinator.com/", true);
+ assertLabelEquals("sql.telemetry.mozilla", "https://sql.telemetry.mozilla.org/", true);
+ assertLabelEquals("sso.mozilla", "http://sso.mozilla.com/", true);
+ assertLabelEquals("youtube", "http://youtube.com/", true);
+ assertLabelEquals("images.google", "http://images.google.com/", true);
+ assertLabelEquals("smile.amazon", "http://smile.amazon.com/", true);
+ assertLabelEquals("localhost", "http://localhost:5000/", true);
+ assertLabelEquals("independent", "http://www.independent.co.uk/", true);
+
+ // With path
+ assertLabelEquals("firefox", "https://addons.mozilla.org/en-US/firefox/", true);
+ assertLabelEquals("activity-stream", "https://trello.com/b/KX3hV8XS/activity-stream", true);
+ assertLabelEquals("activity-stream", "https://github.com/mozilla/activity-stream", true);
+ assertLabelEquals("sidekiq", "https://dispatch-news.herokuapp.com/sidekiq", true);
+ assertLabelEquals("nchapman", "https://github.com/nchapman/", true);
+
+ // Unusable paths
+ assertLabelEquals("phonebook.mozilla","https://phonebook.mozilla.org/mellon/login?ReturnTo=https%3A%2F%2Fphonebook.mozilla.org%2F&IdP=http%3A%2F%2Fwww.okta.com", true);
+ assertLabelEquals("ipay.adp", "https://ipay.adp.com/iPay/index.jsf", true);
+ assertLabelEquals("calendar.google", "https://calendar.google.com/calendar/render?pli=1#main_7", true);
+ assertLabelEquals("myworkday", "https://www.myworkday.com/vhr_mozilla/d/home.htmld", true);
+ assertLabelEquals("mail.google", "https://mail.google.com/mail/u/1/#inbox", true);
+ assertLabelEquals("docs.google", "https://docs.google.com/presentation/d/11cyrcwhKTmBdEBIZ3szLO0-_Imrx2CGV2B9_LZHDrds/edit#slide=id.g15d41bb0f3_0_82", true);
+
+ // Special cases
+ assertLabelEquals("irccloud.mozilla", "https://irccloud.mozilla.com/#!/ircs://irc1.dmz.scl3.mozilla.com:6697/%23universal-search", true);
+ }
+
+ @Test
+ public void testExtractLabelWithoutPath() {
+ assertLabelEquals("addons.mozilla", "https://addons.mozilla.org/en-US/firefox/", false);
+ assertLabelEquals("trello", "https://trello.com/b/KX3hV8XS/activity-stream", false);
+ assertLabelEquals("github", "https://github.com/mozilla/activity-stream", false);
+ assertLabelEquals("dispatch-news", "https://dispatch-news.herokuapp.com/sidekiq", false);
+ assertLabelEquals("github", "https://github.com/nchapman/", false);
+ }
+
+ private void assertLabelEquals(String expectedLabel, String url, boolean usePath) {
+ final String[] actualLabel = new String[1];
+
+ ActivityStream.LabelCallback callback = new ActivityStream.LabelCallback() {
+ @Override
+ public void onLabelExtracted(String label) {
+ actualLabel[0] = label;
+ }
+ };
+
+ ActivityStream.extractLabel(RuntimeEnvironment.application, url, usePath, callback);
+
+ ShadowLooper.runUiThreadTasks();
+
+ assertEquals(expectedLabel, actualLabel[0]);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/common/log/writers/test/TestLogWriters.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/common/log/writers/test/TestLogWriters.java
new file mode 100644
index 000000000..61dd33965
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/common/log/writers/test/TestLogWriters.java
@@ -0,0 +1,179 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.common.log.writers.test;
+
+import android.util.Log;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.common.log.writers.LevelFilteringLogWriter;
+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.StringLogWriter;
+import org.mozilla.gecko.background.common.log.writers.ThreadLocalTagLogWriter;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestLogWriters {
+
+ public static final String TEST_LOG_TAG_1 = "TestLogTag1";
+ public static final String TEST_LOG_TAG_2 = "TestLogTag2";
+
+ public static final String TEST_MESSAGE_1 = "LOG TEST MESSAGE one";
+ public static final String TEST_MESSAGE_2 = "LOG TEST MESSAGE two";
+ public static final String TEST_MESSAGE_3 = "LOG TEST MESSAGE three";
+
+ @Before
+ public void setUp() {
+ Logger.stopLoggingToAll();
+ }
+
+ @After
+ public void tearDown() {
+ Logger.stopLoggingToAll();
+ }
+
+ @Test
+ public void testStringLogWriter() {
+ StringLogWriter lw = new StringLogWriter();
+
+ Logger.error(TEST_LOG_TAG_1, TEST_MESSAGE_1, new RuntimeException());
+ Logger.startLoggingTo(lw);
+ Logger.error(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+ Logger.warn(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+ Logger.info(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+ Logger.debug(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+ Logger.trace(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+ Logger.stopLoggingTo(lw);
+ Logger.error(TEST_LOG_TAG_2, TEST_MESSAGE_3, new RuntimeException());
+
+ String s = lw.toString();
+ assertFalse(s.contains("RuntimeException"));
+ assertFalse(s.contains(".java"));
+ assertTrue(s.contains(TEST_LOG_TAG_1));
+ assertFalse(s.contains(TEST_LOG_TAG_2));
+ assertFalse(s.contains(TEST_MESSAGE_1));
+ assertTrue(s.contains(TEST_MESSAGE_2));
+ assertFalse(s.contains(TEST_MESSAGE_3));
+ }
+
+ @Test
+ public void testSingleTagLogWriter() {
+ final String SINGLE_TAG = "XXX";
+ StringLogWriter lw = new StringLogWriter();
+
+ Logger.startLoggingTo(new SimpleTagLogWriter(SINGLE_TAG, lw));
+ Logger.error(TEST_LOG_TAG_1, TEST_MESSAGE_1);
+ Logger.warn(TEST_LOG_TAG_2, TEST_MESSAGE_2);
+
+ String s = lw.toString();
+ for (String line : s.split("\n")) {
+ assertTrue(line.startsWith(SINGLE_TAG));
+ }
+ assertTrue(s.startsWith(SINGLE_TAG + " :: E :: " + TEST_LOG_TAG_1));
+ }
+
+ @Test
+ public void testLevelFilteringLogWriter() {
+ StringLogWriter lw = new StringLogWriter();
+
+ assertFalse(new LevelFilteringLogWriter(Log.WARN, lw).shouldLogVerbose(TEST_LOG_TAG_1));
+ assertTrue(new LevelFilteringLogWriter(Log.VERBOSE, lw).shouldLogVerbose(TEST_LOG_TAG_1));
+
+ Logger.startLoggingTo(new LevelFilteringLogWriter(Log.WARN, lw));
+ Logger.error(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+ Logger.warn(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+ Logger.info(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+ Logger.debug(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+ Logger.trace(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+
+ String s = lw.toString();
+ assertTrue(s.contains(PrintLogWriter.ERROR));
+ assertTrue(s.contains(PrintLogWriter.WARN));
+ assertFalse(s.contains(PrintLogWriter.INFO));
+ assertFalse(s.contains(PrintLogWriter.DEBUG));
+ assertFalse(s.contains(PrintLogWriter.VERBOSE));
+ }
+
+ @Test
+ public void testThreadLocalLogWriter() throws InterruptedException {
+ final InheritableThreadLocal<String> logTag = new InheritableThreadLocal<String>() {
+ @Override
+ protected String initialValue() {
+ return "PARENT";
+ }
+ };
+
+ final StringLogWriter stringLogWriter = new StringLogWriter();
+ final LogWriter logWriter = new ThreadLocalTagLogWriter(logTag, stringLogWriter);
+
+ try {
+ Logger.startLoggingTo(logWriter);
+
+ Logger.info("parent tag before", "parent message before");
+
+ int threads = 3;
+ final CountDownLatch latch = new CountDownLatch(threads);
+
+ for (int thread = 0; thread < threads; thread++) {
+ final int threadNumber = thread;
+
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ logTag.set("CHILD" + threadNumber);
+ Logger.info("child tag " + threadNumber, "child message " + threadNumber);
+ } finally {
+ latch.countDown();
+ }
+ }
+ }).start();
+ }
+
+ latch.await();
+
+ Logger.info("parent tag after", "parent message after");
+
+ String s = stringLogWriter.toString();
+ List<String> lines = Arrays.asList(s.split("\n"));
+
+ // Because tests are run in a multi-threaded environment, we get
+ // additional logs that are not generated by this test. So we test that we
+ // get all the messages in a reasonable order.
+ try {
+ int parent1 = lines.indexOf("PARENT :: I :: parent tag before :: parent message before");
+ int parent2 = lines.indexOf("PARENT :: I :: parent tag after :: parent message after");
+
+ assertTrue(parent1 >= 0);
+ assertTrue(parent2 >= 0);
+ assertTrue(parent1 < parent2);
+
+ for (int thread = 0; thread < threads; thread++) {
+ int child = lines.indexOf("CHILD" + thread + " :: I :: child tag " + thread + " :: child message " + thread);
+ assertTrue(child >= 0);
+ assertTrue(parent1 < child);
+ assertTrue(child < parent2);
+ }
+ } catch (Throwable e) {
+ // Shouldn't happen. Let's dump to aid debugging.
+ e.printStackTrace();
+ assertEquals("\0", s);
+ }
+ } finally {
+ Logger.stopLoggingTo(logWriter);
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/DelegatingTestContentProvider.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/DelegatingTestContentProvider.java
new file mode 100644
index 000000000..91a36f7d1
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/DelegatingTestContentProvider.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 android.content.ContentProvider;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
+import android.content.ContentValues;
+import android.content.OperationApplicationException;
+import android.database.Cursor;
+import android.net.Uri;
+
+import org.mozilla.gecko.db.BrowserContract;
+
+import java.util.ArrayList;
+
+/**
+ * Wrap a ContentProvider, appending &test=1 to all queries.
+ */
+public class DelegatingTestContentProvider extends ContentProvider {
+ protected final ContentProvider mTargetProvider;
+
+ protected static Uri appendUriParam(Uri uri, String param, String value) {
+ return uri.buildUpon().appendQueryParameter(param, value).build();
+ }
+
+ public DelegatingTestContentProvider(ContentProvider targetProvider) {
+ super();
+ mTargetProvider = targetProvider;
+ }
+
+ private Uri appendTestParam(Uri uri) {
+ return appendUriParam(uri, BrowserContract.PARAM_IS_TEST, "1");
+ }
+
+ @Override
+ public boolean onCreate() {
+ return mTargetProvider.onCreate();
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ return mTargetProvider.getType(uri);
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ return mTargetProvider.delete(appendTestParam(uri), selection, selectionArgs);
+ }
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ return mTargetProvider.insert(appendTestParam(uri), values);
+ }
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ return mTargetProvider.update(appendTestParam(uri), values,
+ selection, selectionArgs);
+ }
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ return mTargetProvider.query(appendTestParam(uri), projection, selection,
+ selectionArgs, sortOrder);
+ }
+
+ @Override
+ public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
+ throws OperationApplicationException {
+ return mTargetProvider.applyBatch(operations);
+ }
+
+ @Override
+ public int bulkInsert(Uri uri, ContentValues[] values) {
+ return mTargetProvider.bulkInsert(appendTestParam(uri), values);
+ }
+
+ public ContentProvider getTargetProvider() {
+ return mTargetProvider;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/TestTabsProvider.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/TestTabsProvider.java
new file mode 100644
index 000000000..f0d468e07
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/TestTabsProvider.java
@@ -0,0 +1,338 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.db;
+
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+
+import org.json.simple.JSONArray;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.TabsProvider;
+import org.mozilla.gecko.sync.repositories.android.BrowserContractHelpers;
+import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository;
+import org.mozilla.gecko.sync.repositories.domain.TabsRecord;
+import org.robolectric.shadows.ShadowContentResolver;
+
+@RunWith(TestRunner.class)
+public class TestTabsProvider {
+ public static final String TEST_CLIENT_GUID = "test guid"; // Real GUIDs never contain spaces.
+ public static final String TEST_CLIENT_NAME = "test client name";
+
+ public static final String CLIENTS_GUID_IS = BrowserContract.Clients.GUID + " = ?";
+ public static final String TABS_CLIENT_GUID_IS = BrowserContract.Tabs.CLIENT_GUID + " = ?";
+
+ protected Tab testTab1;
+ protected Tab testTab2;
+ protected Tab testTab3;
+
+ protected TabsProvider provider;
+
+ @Before
+ public void setUp() {
+ provider = new TabsProvider();
+ provider.onCreate();
+ ShadowContentResolver.registerProvider(BrowserContract.TABS_AUTHORITY, new DelegatingTestContentProvider(provider));
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ provider.shutdown();
+ provider = null;
+ }
+
+ protected ContentProviderClient getClientsClient() {
+ final ShadowContentResolver cr = new ShadowContentResolver();
+ return cr.acquireContentProviderClient(BrowserContractHelpers.CLIENTS_CONTENT_URI);
+ }
+
+ protected ContentProviderClient getTabsClient() {
+ final ShadowContentResolver cr = new ShadowContentResolver();
+ return cr.acquireContentProviderClient(BrowserContractHelpers.TABS_CONTENT_URI);
+ }
+
+ protected int deleteTestClient(final ContentProviderClient clientsClient) throws RemoteException {
+ if (clientsClient == null) {
+ throw new IllegalStateException("Provided ContentProviderClient is null");
+ }
+ return clientsClient.delete(BrowserContractHelpers.CLIENTS_CONTENT_URI, CLIENTS_GUID_IS, new String[] { TEST_CLIENT_GUID });
+ }
+
+ protected int deleteAllTestTabs(final ContentProviderClient tabsClient) throws RemoteException {
+ if (tabsClient == null) {
+ throw new IllegalStateException("Provided ContentProviderClient is null");
+ }
+ return tabsClient.delete(BrowserContractHelpers.TABS_CONTENT_URI, TABS_CLIENT_GUID_IS, new String[] { TEST_CLIENT_GUID });
+ }
+
+ protected void insertTestClient(final ContentProviderClient clientsClient) throws RemoteException {
+ ContentValues cv = new ContentValues();
+ cv.put(BrowserContract.Clients.GUID, TEST_CLIENT_GUID);
+ cv.put(BrowserContract.Clients.NAME, TEST_CLIENT_NAME);
+ clientsClient.insert(BrowserContractHelpers.CLIENTS_CONTENT_URI, cv);
+ }
+
+ @SuppressWarnings("unchecked")
+ protected void insertSomeTestTabs(ContentProviderClient tabsClient) throws RemoteException {
+ final JSONArray history1 = new JSONArray();
+ history1.add("http://test.com/test1.html");
+ testTab1 = new Tab("test title 1", "http://test.com/test1.png", history1, 1000);
+
+ final JSONArray history2 = new JSONArray();
+ history2.add("http://test.com/test2.html#1");
+ history2.add("http://test.com/test2.html#2");
+ history2.add("http://test.com/test2.html#3");
+ testTab2 = new Tab("test title 2", "http://test.com/test2.png", history2, 2000);
+
+ final JSONArray history3 = new JSONArray();
+ history3.add("http://test.com/test3.html#1");
+ history3.add("http://test.com/test3.html#2");
+ testTab3 = new Tab("test title 3", "http://test.com/test3.png", history3, 3000);
+
+ tabsClient.insert(BrowserContractHelpers.TABS_CONTENT_URI, testTab1.toContentValues(TEST_CLIENT_GUID, 0));
+ tabsClient.insert(BrowserContractHelpers.TABS_CONTENT_URI, testTab2.toContentValues(TEST_CLIENT_GUID, 1));
+ tabsClient.insert(BrowserContractHelpers.TABS_CONTENT_URI, testTab3.toContentValues(TEST_CLIENT_GUID, 2));
+ }
+
+ // Sanity.
+ @Test
+ public void testObtainCP() {
+ final ContentProviderClient clientsClient = getClientsClient();
+ Assert.assertNotNull(clientsClient);
+ clientsClient.release();
+
+ final ContentProviderClient tabsClient = getTabsClient();
+ Assert.assertNotNull(tabsClient);
+ tabsClient.release();
+ }
+
+ @Test
+ public void testDeleteEmptyClients() throws RemoteException {
+ final Uri uri = BrowserContractHelpers.CLIENTS_CONTENT_URI;
+ final ContentProviderClient clientsClient = getClientsClient();
+
+ // Have to ensure that it's empty…
+ clientsClient.delete(uri, null, null);
+
+ int deleted = clientsClient.delete(uri, null, null);
+ Assert.assertEquals(0, deleted);
+ }
+
+ @Test
+ public void testDeleteEmptyTabs() throws RemoteException {
+ final ContentProviderClient tabsClient = getTabsClient();
+
+ // Have to ensure that it's empty…
+ deleteAllTestTabs(tabsClient);
+
+ int deleted = deleteAllTestTabs(tabsClient);
+ Assert.assertEquals(0, deleted);
+ }
+
+ @Test
+ public void testStoreAndRetrieveClients() throws RemoteException {
+ final Uri uri = BrowserContractHelpers.CLIENTS_CONTENT_URI;
+ final ContentProviderClient clientsClient = getClientsClient();
+
+ // Have to ensure that it's empty…
+ clientsClient.delete(uri, null, null);
+
+ final long now = System.currentTimeMillis();
+ final ContentValues first = new ContentValues();
+ final ContentValues second = new ContentValues();
+ first.put(BrowserContract.Clients.GUID, "abcdefghijkl");
+ first.put(BrowserContract.Clients.NAME, "Frist Psot");
+ first.put(BrowserContract.Clients.LAST_MODIFIED, now + 1);
+ second.put(BrowserContract.Clients.GUID, "mnopqrstuvwx");
+ second.put(BrowserContract.Clients.NAME, "Second!!1!");
+ second.put(BrowserContract.Clients.LAST_MODIFIED, now + 2);
+
+ ContentValues[] values = new ContentValues[] { first, second };
+ final int inserted = clientsClient.bulkInsert(uri, values);
+ Assert.assertEquals(2, inserted);
+
+ final String since = BrowserContract.Clients.LAST_MODIFIED + " >= ?";
+ final String[] nowArg = new String[] { String.valueOf(now) };
+ final String guidAscending = BrowserContract.Clients.GUID + " ASC";
+ Cursor cursor = clientsClient.query(uri, null, since, nowArg, guidAscending);
+
+ Assert.assertNotNull(cursor);
+ try {
+ Assert.assertTrue(cursor.moveToFirst());
+ Assert.assertEquals(2, cursor.getCount());
+
+ final String g1 = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Clients.GUID));
+ final String n1 = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Clients.NAME));
+ final long m1 = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Clients.LAST_MODIFIED));
+ Assert.assertEquals(first.get(BrowserContract.Clients.GUID), g1);
+ Assert.assertEquals(first.get(BrowserContract.Clients.NAME), n1);
+ Assert.assertEquals(now + 1, m1);
+
+ Assert.assertTrue(cursor.moveToNext());
+ final String g2 = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Clients.GUID));
+ final String n2 = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Clients.NAME));
+ final long m2 = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Clients.LAST_MODIFIED));
+ Assert.assertEquals(second.get(BrowserContract.Clients.GUID), g2);
+ Assert.assertEquals(second.get(BrowserContract.Clients.NAME), n2);
+ Assert.assertEquals(now + 2, m2);
+
+ Assert.assertFalse(cursor.moveToNext());
+ } finally {
+ cursor.close();
+ }
+
+ int deleted = clientsClient.delete(uri, null, null);
+ Assert.assertEquals(2, deleted);
+ }
+
+ @Test
+ public void testTabFromCursor() throws Exception {
+ final ContentProviderClient tabsClient = getTabsClient();
+ final ContentProviderClient clientsClient = getClientsClient();
+
+ deleteAllTestTabs(tabsClient);
+ deleteTestClient(clientsClient);
+ insertTestClient(clientsClient);
+ insertSomeTestTabs(tabsClient);
+
+ final String positionAscending = BrowserContract.Tabs.POSITION + " ASC";
+ Cursor cursor = null;
+ try {
+ cursor = tabsClient.query(BrowserContractHelpers.TABS_CONTENT_URI, null, TABS_CLIENT_GUID_IS, new String[] { TEST_CLIENT_GUID }, positionAscending);
+ Assert.assertEquals(3, cursor.getCount());
+
+ cursor.moveToFirst();
+ final Tab parsed1 = Tab.fromCursor(cursor);
+ Assert.assertEquals(testTab1, parsed1);
+
+ cursor.moveToNext();
+ final Tab parsed2 = Tab.fromCursor(cursor);
+ Assert.assertEquals(testTab2, parsed2);
+
+ cursor.moveToPosition(2);
+ final Tab parsed3 = Tab.fromCursor(cursor);
+ Assert.assertEquals(testTab3, parsed3);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ @Test
+ public void testDeletingClientDeletesTabs() throws Exception {
+ final ContentProviderClient tabsClient = getTabsClient();
+ final ContentProviderClient clientsClient = getClientsClient();
+
+ deleteAllTestTabs(tabsClient);
+ deleteTestClient(clientsClient);
+ insertTestClient(clientsClient);
+ insertSomeTestTabs(tabsClient);
+
+ // Delete just the client...
+ clientsClient.delete(BrowserContractHelpers.CLIENTS_CONTENT_URI, CLIENTS_GUID_IS, new String [] { TEST_CLIENT_GUID });
+
+ Cursor cursor = null;
+ try {
+ cursor = tabsClient.query(BrowserContractHelpers.TABS_CONTENT_URI, null, TABS_CLIENT_GUID_IS, new String[] { TEST_CLIENT_GUID }, null);
+ // ... and all that client's tabs should be removed.
+ Assert.assertEquals(0, cursor.getCount());
+ } finally {
+ cursor.close();
+ }
+ }
+
+ @Test
+ public void testTabsRecordFromCursor() throws Exception {
+ final ContentProviderClient tabsClient = getTabsClient();
+
+ deleteAllTestTabs(tabsClient);
+ insertTestClient(getClientsClient());
+ insertSomeTestTabs(tabsClient);
+
+ final String positionAscending = BrowserContract.Tabs.POSITION + " ASC";
+ Cursor cursor = null;
+ try {
+ cursor = tabsClient.query(BrowserContractHelpers.TABS_CONTENT_URI, null, TABS_CLIENT_GUID_IS, new String[] { TEST_CLIENT_GUID }, positionAscending);
+ Assert.assertEquals(3, cursor.getCount());
+
+ cursor.moveToPosition(1);
+
+ final TabsRecord tabsRecord = FennecTabsRepository.tabsRecordFromCursor(cursor, TEST_CLIENT_GUID, TEST_CLIENT_NAME);
+
+ // Make sure we clean up after ourselves.
+ Assert.assertEquals(1, cursor.getPosition());
+
+ Assert.assertEquals(TEST_CLIENT_GUID, tabsRecord.guid);
+ Assert.assertEquals(TEST_CLIENT_NAME, tabsRecord.clientName);
+
+ Assert.assertEquals(3, tabsRecord.tabs.size());
+ Assert.assertEquals(testTab1, tabsRecord.tabs.get(0));
+ Assert.assertEquals(testTab2, tabsRecord.tabs.get(1));
+ Assert.assertEquals(testTab3, tabsRecord.tabs.get(2));
+
+ Assert.assertEquals(Math.max(Math.max(testTab1.lastUsed, testTab2.lastUsed), testTab3.lastUsed), tabsRecord.lastModified);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ // Verify that we can fetch a record when there are no local tabs at all.
+ @Test
+ public void testEmptyTabsRecordFromCursor() throws Exception {
+ final ContentProviderClient tabsClient = getTabsClient();
+
+ deleteAllTestTabs(tabsClient);
+
+ final String positionAscending = BrowserContract.Tabs.POSITION + " ASC";
+ Cursor cursor = null;
+ try {
+ cursor = tabsClient.query(BrowserContractHelpers.TABS_CONTENT_URI, null, TABS_CLIENT_GUID_IS, new String[] { TEST_CLIENT_GUID }, positionAscending);
+ Assert.assertEquals(0, cursor.getCount());
+
+ final TabsRecord tabsRecord = FennecTabsRepository.tabsRecordFromCursor(cursor, TEST_CLIENT_GUID, TEST_CLIENT_NAME);
+
+ Assert.assertEquals(TEST_CLIENT_GUID, tabsRecord.guid);
+ Assert.assertEquals(TEST_CLIENT_NAME, tabsRecord.clientName);
+
+ Assert.assertNotNull(tabsRecord.tabs);
+ Assert.assertEquals(0, tabsRecord.tabs.size());
+
+ Assert.assertEquals(0, tabsRecord.lastModified);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ // Not much of a test, but verifies the tabs record at least agrees with the
+ // disk data and doubles as a database inspector.
+ @Test
+ public void testLocalTabs() throws Exception {
+ final ContentProviderClient tabsClient = getTabsClient();
+
+ final String positionAscending = BrowserContract.Tabs.POSITION + " ASC";
+ Cursor cursor = null;
+ try {
+ // Keep this in sync with the Fennec schema.
+ cursor = tabsClient.query(BrowserContractHelpers.TABS_CONTENT_URI, null, BrowserContract.Tabs.CLIENT_GUID + " IS NULL", null, positionAscending);
+ CursorDumper.dumpCursor(cursor);
+
+ final TabsRecord tabsRecord = FennecTabsRepository.tabsRecordFromCursor(cursor, TEST_CLIENT_GUID, TEST_CLIENT_NAME);
+
+ Assert.assertEquals(TEST_CLIENT_GUID, tabsRecord.guid);
+ Assert.assertEquals(TEST_CLIENT_NAME, tabsRecord.clientName);
+
+ Assert.assertNotNull(tabsRecord.tabs);
+ Assert.assertEquals(cursor.getCount(), tabsRecord.tabs.size());
+ } finally {
+ cursor.close();
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/TestTabsProviderRemoteTabs.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/TestTabsProviderRemoteTabs.java
new file mode 100644
index 000000000..e63cb9b46
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/TestTabsProviderRemoteTabs.java
@@ -0,0 +1,244 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.db;
+
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.LocalTabsAccessor;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.db.TabsProvider;
+import org.mozilla.gecko.sync.repositories.android.BrowserContractHelpers;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.internal.runtime.RuntimeAdapter;
+import org.robolectric.shadows.ShadowContentResolver;
+
+import java.util.List;
+
+@RunWith(TestRunner.class)
+public class TestTabsProviderRemoteTabs {
+ private static final long ONE_DAY_IN_MILLISECONDS = 1000 * 60 * 60 * 24;
+ private static final long ONE_WEEK_IN_MILLISECONDS = 7 * ONE_DAY_IN_MILLISECONDS;
+ private static final long THREE_WEEKS_IN_MILLISECONDS = 3 * ONE_WEEK_IN_MILLISECONDS;
+
+ protected TabsProvider provider;
+
+ @Before
+ public void setUp() {
+ provider = new TabsProvider();
+ provider.onCreate();
+ ShadowContentResolver.registerProvider(BrowserContract.TABS_AUTHORITY, new DelegatingTestContentProvider(provider));
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ provider.shutdown();
+ provider = null;
+ }
+
+ protected ContentProviderClient getClientsClient() {
+ final ShadowContentResolver cr = new ShadowContentResolver();
+ return cr.acquireContentProviderClient(BrowserContractHelpers.CLIENTS_CONTENT_URI);
+ }
+
+ @Test
+ public void testGetClientsWithoutTabsByRecencyFromCursor() throws Exception {
+ final Uri uri = BrowserContractHelpers.CLIENTS_CONTENT_URI;
+ final ContentProviderClient cpc = getClientsClient();
+ final LocalTabsAccessor accessor = new LocalTabsAccessor("test"); // The profile name given doesn't matter.
+
+ try {
+ // Delete all tabs to begin with.
+ cpc.delete(uri, null, null);
+ Cursor allClients = cpc.query(uri, null, null, null, null);
+ try {
+ Assert.assertEquals(0, allClients.getCount());
+ } finally {
+ allClients.close();
+ }
+
+ // Insert a local and remote1 client record, neither with tabs.
+ final long now = System.currentTimeMillis();
+ // Local client has GUID = null.
+ final ContentValues local = new ContentValues();
+ local.put(BrowserContract.Clients.NAME, "local");
+ local.put(BrowserContract.Clients.LAST_MODIFIED, now + 1);
+ // Remote clients have GUID != null.
+ final ContentValues remote1 = new ContentValues();
+ remote1.put(BrowserContract.Clients.GUID, "guid1");
+ remote1.put(BrowserContract.Clients.NAME, "remote1");
+ remote1.put(BrowserContract.Clients.LAST_MODIFIED, now + 2);
+
+ final ContentValues remote2 = new ContentValues();
+ remote2.put(BrowserContract.Clients.GUID, "guid2");
+ remote2.put(BrowserContract.Clients.NAME, "remote2");
+ remote2.put(BrowserContract.Clients.LAST_MODIFIED, now + 3);
+
+ ContentValues[] values = new ContentValues[]{local, remote1, remote2};
+ int inserted = cpc.bulkInsert(uri, values);
+ Assert.assertEquals(3, inserted);
+
+ allClients = cpc.query(BrowserContract.Clients.CONTENT_RECENCY_URI, null, null, null, null);
+ try {
+ CursorDumper.dumpCursor(allClients);
+ // The local client is not ignored.
+ Assert.assertEquals(3, allClients.getCount());
+ final List<RemoteClient> clients = accessor.getClientsWithoutTabsByRecencyFromCursor(allClients);
+ Assert.assertEquals(3, clients.size());
+ for (RemoteClient client : clients) {
+ // Each client should not have any tabs.
+ Assert.assertNotNull(client.tabs);
+ Assert.assertEquals(0, client.tabs.size());
+ }
+ // Since there are no tabs, the order should be based on last_modified.
+ Assert.assertEquals("guid2", clients.get(0).guid);
+ Assert.assertEquals("guid1", clients.get(1).guid);
+ Assert.assertEquals(null, clients.get(2).guid);
+ } finally {
+ allClients.close();
+ }
+
+ // Now let's add a few tabs to one client. The times are chosen so that one tab's
+ // last used is not relevant, and the other tab is the most recent used.
+ final ContentValues remoteTab1 = new ContentValues();
+ remoteTab1.put(BrowserContract.Tabs.CLIENT_GUID, "guid1");
+ remoteTab1.put(BrowserContract.Tabs.TITLE, "title1");
+ remoteTab1.put(BrowserContract.Tabs.URL, "http://test.com/test1");
+ remoteTab1.put(BrowserContract.Tabs.HISTORY, "[\"http://test.com/test1\"]");
+ remoteTab1.put(BrowserContract.Tabs.LAST_USED, now);
+ remoteTab1.put(BrowserContract.Tabs.POSITION, 0);
+
+ final ContentValues remoteTab2 = new ContentValues();
+ remoteTab2.put(BrowserContract.Tabs.CLIENT_GUID, "guid1");
+ remoteTab2.put(BrowserContract.Tabs.TITLE, "title2");
+ remoteTab2.put(BrowserContract.Tabs.URL, "http://test.com/test2");
+ remoteTab2.put(BrowserContract.Tabs.HISTORY, "[\"http://test.com/test2\"]");
+ remoteTab2.put(BrowserContract.Tabs.LAST_USED, now + 5);
+ remoteTab2.put(BrowserContract.Tabs.POSITION, 1);
+
+ values = new ContentValues[]{remoteTab1, remoteTab2};
+ inserted = cpc.bulkInsert(BrowserContract.Tabs.CONTENT_URI, values);
+ Assert.assertEquals(2, inserted);
+
+ allClients = cpc.query(BrowserContract.Clients.CONTENT_RECENCY_URI, null, BrowserContract.Clients.GUID + " IS NOT NULL", null, null);
+ try {
+ CursorDumper.dumpCursor(allClients);
+ // The local client is ignored.
+ Assert.assertEquals(2, allClients.getCount());
+ final List<RemoteClient> clients = accessor.getClientsWithoutTabsByRecencyFromCursor(allClients);
+ Assert.assertEquals(2, clients.size());
+ for (RemoteClient client : clients) {
+ // Each client should be remote and should not have any tabs.
+ Assert.assertNotNull(client.guid);
+ Assert.assertNotNull(client.tabs);
+ Assert.assertEquals(0, client.tabs.size());
+ }
+ // Since now there is a tab attached to the remote2 client more recent than the
+ // remote1 client modified time, it should be first.
+ Assert.assertEquals("guid1", clients.get(0).guid);
+ Assert.assertEquals("guid2", clients.get(1).guid);
+ } finally {
+ allClients.close();
+ }
+ } finally {
+ cpc.release();
+ }
+ }
+
+ @Test
+ public void testGetRecentRemoteClientsUpToOneWeekOld() throws Exception {
+ final Uri uri = BrowserContractHelpers.CLIENTS_CONTENT_URI;
+ final ContentProviderClient cpc = getClientsClient();
+ final LocalTabsAccessor accessor = new LocalTabsAccessor("test"); // The profile name given doesn't matter.
+ final Context context = RuntimeEnvironment.application.getApplicationContext();
+
+ try {
+ // Start Clean
+ cpc.delete(uri, null, null);
+ final Cursor allClients = cpc.query(uri, null, null, null, null);
+ try {
+ Assert.assertEquals(0, allClients.getCount());
+ } finally {
+ allClients.close();
+ }
+
+ // Insert a local and remote1 client record, neither with tabs.
+ final long now = System.currentTimeMillis();
+ // Local client has GUID = null.
+ final ContentValues local = new ContentValues();
+ local.put(BrowserContract.Clients.NAME, "local");
+ local.put(BrowserContract.Clients.LAST_MODIFIED, now + 1);
+ // Remote clients have GUID != null.
+ final ContentValues remote1 = new ContentValues();
+ remote1.put(BrowserContract.Clients.GUID, "guid1");
+ remote1.put(BrowserContract.Clients.NAME, "remote1");
+ remote1.put(BrowserContract.Clients.LAST_MODIFIED, now + 2);
+
+ // Insert a Remote Client that is 6 days old.
+ final ContentValues remote2 = new ContentValues();
+ remote2.put(BrowserContract.Clients.GUID, "guid2");
+ remote2.put(BrowserContract.Clients.NAME, "remote2");
+ remote2.put(BrowserContract.Clients.LAST_MODIFIED, now - ONE_WEEK_IN_MILLISECONDS + ONE_DAY_IN_MILLISECONDS);
+
+ // Insert a Remote Client with the same name as previous but with more than 3 weeks old
+ final ContentValues remote3 = new ContentValues();
+ remote3.put(BrowserContract.Clients.GUID, "guid21");
+ remote3.put(BrowserContract.Clients.NAME, "remote2");
+ remote3.put(BrowserContract.Clients.LAST_MODIFIED, now - THREE_WEEKS_IN_MILLISECONDS - ONE_DAY_IN_MILLISECONDS);
+
+ // Insert another remote client with the same name as previous but with 3 weeks - 1 day old.
+ final ContentValues remote4 = new ContentValues();
+ remote4.put(BrowserContract.Clients.GUID, "guid22");
+ remote4.put(BrowserContract.Clients.NAME, "remote2");
+ remote4.put(BrowserContract.Clients.LAST_MODIFIED, now - THREE_WEEKS_IN_MILLISECONDS + ONE_DAY_IN_MILLISECONDS);
+
+ // Insert a Remote Client that is exactly one week old.
+ final ContentValues remote5 = new ContentValues();
+ remote5.put(BrowserContract.Clients.GUID, "guid3");
+ remote5.put(BrowserContract.Clients.NAME, "remote3");
+ remote5.put(BrowserContract.Clients.LAST_MODIFIED, now - ONE_WEEK_IN_MILLISECONDS);
+
+ ContentValues[] values = new ContentValues[]{local, remote1, remote2, remote3, remote4, remote5};
+ int inserted = cpc.bulkInsert(uri, values);
+ Assert.assertEquals(values.length, inserted);
+
+ final Cursor remoteClients =
+ accessor.getRemoteClientsByRecencyCursor(context);
+
+ try {
+ CursorDumper.dumpCursor(remoteClients);
+ // Local client is not included.
+ // (remote1, guid1), (remote2, guid2), (remote3, guid3) are expected.
+ Assert.assertEquals(3, remoteClients.getCount());
+
+ // Check the inner data, according to recency.
+ List<RemoteClient> recentRemoteClientsList =
+ accessor.getClientsWithoutTabsByRecencyFromCursor(remoteClients);
+ Assert.assertEquals(3, recentRemoteClientsList.size());
+ Assert.assertEquals("remote1", recentRemoteClientsList.get(0).name);
+ Assert.assertEquals("guid1", recentRemoteClientsList.get(0).guid);
+ Assert.assertEquals("remote2", recentRemoteClientsList.get(1).name);
+ Assert.assertEquals("guid2", recentRemoteClientsList.get(1).guid);
+ Assert.assertEquals("remote3", recentRemoteClientsList.get(2).name);
+ Assert.assertEquals("guid3", recentRemoteClientsList.get(2).guid);
+ } finally {
+ remoteClients.close();
+ }
+ } finally {
+ cpc.release();
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountClient20.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountClient20.java
new file mode 100644
index 000000000..d075cc0ec
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountClient20.java
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.fxa.test;
+
+import junit.framework.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.net.BaseResource;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URISyntaxException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+@RunWith(TestRunner.class)
+public class TestFxAccountClient20 {
+ protected static class MockFxAccountClient20 extends FxAccountClient20 {
+ public MockFxAccountClient20(String serverURI, Executor executor) {
+ super(serverURI, executor);
+ }
+
+ // Public for testing.
+ @Override
+ public BaseResource getBaseResource(final String path, final String... queryParameters) throws UnsupportedEncodingException, URISyntaxException {
+ return super.getBaseResource(path, queryParameters);
+ }
+ }
+
+ @Test
+ public void testGetCreateAccountURI() throws Exception {
+ final String TEST_SERVER = "https://test.com:4430/inner/v1/";
+ final MockFxAccountClient20 client = new MockFxAccountClient20(TEST_SERVER, Executors.newSingleThreadExecutor());
+ Assert.assertEquals(TEST_SERVER + "account/create", client.getBaseResource("account/create").getURIString());
+ Assert.assertEquals(TEST_SERVER + "account/create?service=sync&keys=true", client.getBaseResource("account/create", "service", "sync", "keys", "true").getURIString());
+ Assert.assertEquals(TEST_SERVER + "account/create?service=two+words", client.getBaseResource("account/create", "service", "two words").getURIString());
+ Assert.assertEquals(TEST_SERVER + "account/create?service=symbols%2F%3A%3F%2B", client.getBaseResource("account/create", "service", "symbols/:?+").getURIString());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountUtils.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountUtils.java
new file mode 100644
index 000000000..e6461776e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountUtils.java
@@ -0,0 +1,131 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.fxa.test;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.net.SRPConstants;
+
+import java.math.BigInteger;
+
+/**
+ * Test vectors from
+ * <a href="https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#stretch-KDF">https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#stretch-KDF</a>
+ * and
+ * <a href="https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol/5a9bc81e499306d769ca19b40b50fa60123df15d">https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol/5a9bc81e499306d769ca19b40b50fa60123df15d</a>.
+ */
+@RunWith(TestRunner.class)
+public class TestFxAccountUtils {
+ protected static void assertEncoding(String base16String, String utf8String) throws Exception {
+ Assert.assertEquals(base16String, FxAccountUtils.bytes(utf8String));
+ }
+
+ @Test
+ public void testUTF8Encoding() throws Exception {
+ assertEncoding("616e6472c3a9406578616d706c652e6f7267", "andré@example.org");
+ assertEncoding("70c3a4737377c3b67264", "pässwörd");
+ }
+
+ @Test
+ public void testHexModN() {
+ BigInteger N = BigInteger.valueOf(14);
+ Assert.assertEquals(4, N.bitLength());
+ Assert.assertEquals(1, (N.bitLength() + 7)/8);
+ Assert.assertEquals("00", FxAccountUtils.hexModN(BigInteger.valueOf(0), N));
+ Assert.assertEquals("05", FxAccountUtils.hexModN(BigInteger.valueOf(5), N));
+ Assert.assertEquals("0b", FxAccountUtils.hexModN(BigInteger.valueOf(11), N));
+ Assert.assertEquals("00", FxAccountUtils.hexModN(BigInteger.valueOf(14), N));
+ Assert.assertEquals("01", FxAccountUtils.hexModN(BigInteger.valueOf(15), N));
+ Assert.assertEquals("02", FxAccountUtils.hexModN(BigInteger.valueOf(16), N));
+ Assert.assertEquals("02", FxAccountUtils.hexModN(BigInteger.valueOf(30), N));
+
+ N = BigInteger.valueOf(260);
+ Assert.assertEquals("00ff", FxAccountUtils.hexModN(BigInteger.valueOf(255), N));
+ Assert.assertEquals("0100", FxAccountUtils.hexModN(BigInteger.valueOf(256), N));
+ Assert.assertEquals("0101", FxAccountUtils.hexModN(BigInteger.valueOf(257), N));
+ Assert.assertEquals("0001", FxAccountUtils.hexModN(BigInteger.valueOf(261), N));
+ }
+
+ @Test
+ public void testSRPVerifierFunctions() throws Exception {
+ byte[] emailUTF8Bytes = Utils.hex2Byte("616e6472c3a9406578616d706c652e6f7267");
+ byte[] srpPWBytes = Utils.hex2Byte("00f9b71800ab5337d51177d8fbc682a3653fa6dae5b87628eeec43a18af59a9d", 32);
+ byte[] srpSaltBytes = Utils.hex2Byte("00f1000000000000000000000000000000000000000000000000000000000179", 32);
+
+ String expectedX = "81925186909189958012481408070938147619474993903899664126296984459627523279550";
+ BigInteger x = FxAccountUtils.srpVerifierLowercaseX(emailUTF8Bytes, srpPWBytes, srpSaltBytes);
+ Assert.assertEquals(expectedX, x.toString(10));
+
+ String expectedV = "11464957230405843056840989945621595830717843959177257412217395741657995431613430369165714029818141919887853709633756255809680435884948698492811770122091692817955078535761033207000504846365974552196983218225819721112680718485091921646083608065626264424771606096544316730881455897489989950697705196721477608178869100211706638584538751009854562396937282582855620488967259498367841284829152987988548996842770025110751388952323221706639434861071834212055174768483159061566055471366772641252573641352721966728239512914666806496255304380341487975080159076396759492553066357163103546373216130193328802116982288883318596822";
+ BigInteger v = FxAccountUtils.srpVerifierLowercaseV(emailUTF8Bytes, srpPWBytes, srpSaltBytes, SRPConstants._2048.g, SRPConstants._2048.N);
+ Assert.assertEquals(expectedV, v.toString(10));
+
+ String expectedVHex = "00173ffa0263e63ccfd6791b8ee2a40f048ec94cd95aa8a3125726f9805e0c8283c658dc0b607fbb25db68e68e93f2658483049c68af7e8214c49fde2712a775b63e545160d64b00189a86708c69657da7a1678eda0cd79f86b8560ebdb1ffc221db360eab901d643a75bf1205070a5791230ae56466b8c3c1eb656e19b794f1ea0d2a077b3a755350208ea0118fec8c4b2ec344a05c66ae1449b32609ca7189451c259d65bd15b34d8729afdb5faff8af1f3437bbdc0c3d0b069a8ab2a959c90c5a43d42082c77490f3afcc10ef5648625c0605cdaace6c6fdc9e9a7e6635d619f50af7734522470502cab26a52a198f5b00a279858916507b0b4e9ef9524d6";
+ Assert.assertEquals(expectedVHex, FxAccountUtils.hexModN(v, SRPConstants._2048.N));
+ }
+
+ @Test
+ public void testGenerateSyncKeyBundle() throws Exception {
+ byte[] kB = Utils.hex2Byte("d02d8fe39f28b601159c543f2deeb8f72bdf2043e8279aa08496fbd9ebaea361");
+ KeyBundle bundle = FxAccountUtils.generateSyncKeyBundle(kB);
+ Assert.assertEquals("rsLwECkgPYeGbYl92e23FskfIbgld9TgeifEaB9ZwTI=", Base64.encodeBase64String(bundle.getEncryptionKey()));
+ Assert.assertEquals("fs75EseCD/VOLodlIGmwNabBjhTYBHFCe7CGIf0t8Tw=", Base64.encodeBase64String(bundle.getHMACKey()));
+ }
+
+ @Test
+ public void testGeneration() throws Exception {
+ byte[] quickStretchedPW = FxAccountUtils.generateQuickStretchedPW(
+ Utils.hex2Byte("616e6472c3a9406578616d706c652e6f7267"),
+ Utils.hex2Byte("70c3a4737377c3b67264"));
+ Assert.assertEquals("e4e8889bd8bd61ad6de6b95c059d56e7b50dacdaf62bd84644af7e2add84345d",
+ Utils.byte2Hex(quickStretchedPW));
+ Assert.assertEquals("247b675ffb4c46310bc87e26d712153abe5e1c90ef00a4784594f97ef54f2375",
+ Utils.byte2Hex(FxAccountUtils.generateAuthPW(quickStretchedPW)));
+ byte[] unwrapkB = FxAccountUtils.generateUnwrapBKey(quickStretchedPW);
+ Assert.assertEquals("de6a2648b78284fcb9ffa81ba95803309cfba7af583c01a8a1a63e567234dd28",
+ Utils.byte2Hex(unwrapkB));
+ byte[] wrapkB = Utils.hex2Byte("7effe354abecbcb234a8dfc2d7644b4ad339b525589738f2d27341bb8622ecd8");
+ Assert.assertEquals("a095c51c1c6e384e8d5777d97e3c487a4fc2128a00ab395a73d57fedf41631f0",
+ Utils.byte2Hex(FxAccountUtils.unwrapkB(unwrapkB, wrapkB)));
+ }
+
+ @Test
+ public void testClientState() throws Exception {
+ final String hexKB = "fd5c747806c07ce0b9d69dcfea144663e630b65ec4963596a22f24910d7dd15d";
+ final byte[] byteKB = Utils.hex2Byte(hexKB);
+ final String clientState = FxAccountUtils.computeClientState(byteKB);
+ final String expected = "6ae94683571c7a7c54dab4700aa3995f";
+ Assert.assertEquals(expected, clientState);
+ }
+
+ @Test
+ public void testGetAudienceForURL() throws Exception {
+ // Sub-domains and path components.
+ Assert.assertEquals("http://sub.test.com", FxAccountUtils.getAudienceForURL("http://sub.test.com"));
+ Assert.assertEquals("http://test.com", FxAccountUtils.getAudienceForURL("http://test.com/"));
+ Assert.assertEquals("http://test.com", FxAccountUtils.getAudienceForURL("http://test.com/path/component"));
+ Assert.assertEquals("http://test.com", FxAccountUtils.getAudienceForURL("http://test.com/path/component/"));
+
+ // No port and default port.
+ Assert.assertEquals("http://test.com", FxAccountUtils.getAudienceForURL("http://test.com"));
+ Assert.assertEquals("http://test.com:80", FxAccountUtils.getAudienceForURL("http://test.com:80"));
+
+ Assert.assertEquals("https://test.com", FxAccountUtils.getAudienceForURL("https://test.com"));
+ Assert.assertEquals("https://test.com:443", FxAccountUtils.getAudienceForURL("https://test.com:443"));
+
+ // Ports that are the default ports for a different scheme.
+ Assert.assertEquals("https://test.com:80", FxAccountUtils.getAudienceForURL("https://test.com:80"));
+ Assert.assertEquals("http://test.com:443", FxAccountUtils.getAudienceForURL("http://test.com:443"));
+
+ // Arbitrary ports.
+ Assert.assertEquals("http://test.com:8080", FxAccountUtils.getAudienceForURL("http://test.com:8080"));
+ Assert.assertEquals("https://test.com:4430", FxAccountUtils.getAudienceForURL("https://test.com:4430"));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/test/EntityTestHelper.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/test/EntityTestHelper.java
new file mode 100644
index 000000000..976a8eda1
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/test/EntityTestHelper.java
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.test;
+
+import ch.boye.httpclientandroidlib.HttpEntity;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class EntityTestHelper {
+ private static final int DEFAULT_SIZE = 1024;
+
+ public static byte[] bytesFromEntity(final HttpEntity entity) throws IOException {
+ final InputStream is = entity.getContent();
+
+ if (is instanceof ByteArrayInputStream) {
+ final int size = is.available();
+ final byte[] buffer = new byte[size];
+ is.read(buffer, 0, size);
+ return buffer;
+ }
+
+ final ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ final byte[] buffer = new byte[DEFAULT_SIZE];
+ int len;
+ while ((len = is.read(buffer, 0, DEFAULT_SIZE)) != -1) {
+ bos.write(buffer, 0, len);
+ }
+ return bos.toByteArray();
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/BaseMockServerSyncStage.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/BaseMockServerSyncStage.java
new file mode 100644
index 000000000..d9aa936f0
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/BaseMockServerSyncStage.java
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import org.mozilla.gecko.sync.NoCollectionKeysSetException;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.SynchronizerConfiguration;
+import org.mozilla.gecko.sync.repositories.RecordFactory;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.stage.ServerSyncStage;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+
+/**
+ * A stage that joins two Repositories with no wrapping.
+ */
+public abstract class BaseMockServerSyncStage extends ServerSyncStage {
+
+ public Repository local;
+ public Repository remote;
+ public String name;
+ public String collection;
+ public int version = 1;
+
+ @Override
+ public boolean isEnabled() {
+ return true;
+ }
+
+ @Override
+ protected String getCollection() {
+ return collection;
+ }
+
+ @Override
+ protected Repository getLocalRepository() {
+ return local;
+ }
+
+ @Override
+ protected Repository getRemoteRepository() throws URISyntaxException {
+ return remote;
+ }
+
+ @Override
+ protected String getEngineName() {
+ return name;
+ }
+
+ @Override
+ public Integer getStorageVersion() {
+ return version;
+ }
+
+ @Override
+ protected RecordFactory getRecordFactory() {
+ return null;
+ }
+
+ @Override
+ protected Repository wrappedServerRepo()
+ throws NoCollectionKeysSetException, URISyntaxException {
+ return getRemoteRepository();
+ }
+
+ public SynchronizerConfiguration leakConfig()
+ throws NonObjectJSONException, IOException {
+ return this.getConfig();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/CommandHelpers.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/CommandHelpers.java
new file mode 100644
index 000000000..48217f1b0
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/CommandHelpers.java
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.sync.CommandProcessor.Command;
+
+public class CommandHelpers {
+
+ @SuppressWarnings("unchecked")
+ public static Command getCommand1() {
+ JSONArray args = new JSONArray();
+ args.add("argsA");
+ return new Command("displayURI", args);
+ }
+
+ @SuppressWarnings("unchecked")
+ public static Command getCommand2() {
+ JSONArray args = new JSONArray();
+ args.add("argsB");
+ return new Command("displayURI", args);
+ }
+
+ @SuppressWarnings("unchecked")
+ public static Command getCommand3() {
+ JSONArray args = new JSONArray();
+ args.add("argsC");
+ return new Command("displayURI", args);
+ }
+
+ @SuppressWarnings("unchecked")
+ public static Command getCommand4() {
+ JSONArray args = new JSONArray();
+ args.add("URI of Page");
+ args.add("Sender ID");
+ args.add("Title of Page");
+ return new Command("displayURI", args);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/DefaultGlobalSessionCallback.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/DefaultGlobalSessionCallback.java
new file mode 100644
index 000000000..373dd4eab
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/DefaultGlobalSessionCallback.java
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+
+import java.net.URI;
+
+public class DefaultGlobalSessionCallback implements GlobalSessionCallback {
+
+ @Override
+ public void requestBackoff(long backoff) {
+ }
+
+ @Override
+ public void informUnauthorizedResponse(GlobalSession globalSession,
+ URI oldClusterURL) {
+ }
+ @Override
+ public void informUpgradeRequiredResponse(GlobalSession session) {
+ }
+
+ @Override
+ public void informMigrated(GlobalSession globalSession) {
+ }
+
+ @Override
+ public void handleAborted(GlobalSession globalSession, String reason) {
+ }
+
+ @Override
+ public void handleError(GlobalSession globalSession, Exception ex) {
+ }
+
+ @Override
+ public void handleSuccess(GlobalSession globalSession) {
+ }
+
+ @Override
+ public void handleStageCompleted(Stage currentState,
+ GlobalSession globalSession) {
+ }
+
+ @Override
+ public boolean shouldBackOffStorage() {
+ return false;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockAbstractNonRepositorySyncStage.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockAbstractNonRepositorySyncStage.java
new file mode 100644
index 000000000..d8380df97
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockAbstractNonRepositorySyncStage.java
@@ -0,0 +1,13 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import org.mozilla.gecko.sync.stage.AbstractNonRepositorySyncStage;
+
+public class MockAbstractNonRepositorySyncStage extends AbstractNonRepositorySyncStage {
+ @Override
+ public void execute() {
+ session.advance();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockClientsDataDelegate.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockClientsDataDelegate.java
new file mode 100644
index 000000000..f4af51f64
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockClientsDataDelegate.java
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
+
+public class MockClientsDataDelegate implements ClientsDataDelegate {
+ private String accountGUID;
+ private String clientName;
+ private int clientsCount;
+ private long clientDataTimestamp = 0;
+
+ @Override
+ public synchronized String getAccountGUID() {
+ if (accountGUID == null) {
+ accountGUID = Utils.generateGuid();
+ }
+ return accountGUID;
+ }
+
+ @Override
+ public synchronized String getDefaultClientName() {
+ return "Default client";
+ }
+
+ @Override
+ public synchronized void setClientName(String clientName, long now) {
+ this.clientName = clientName;
+ this.clientDataTimestamp = now;
+ }
+
+ @Override
+ public synchronized String getClientName() {
+ if (clientName == null) {
+ setClientName(getDefaultClientName(), System.currentTimeMillis());
+ }
+ return clientName;
+ }
+
+ @Override
+ public synchronized void setClientsCount(int clientsCount) {
+ this.clientsCount = clientsCount;
+ }
+
+ @Override
+ public synchronized int getClientsCount() {
+ return clientsCount;
+ }
+
+ @Override
+ public synchronized boolean isLocalGUID(String guid) {
+ return getAccountGUID().equals(guid);
+ }
+
+ @Override
+ public synchronized long getLastModifiedTimestamp() {
+ return clientDataTimestamp;
+ }
+
+ @Override
+ public String getFormFactor() {
+ return "phone";
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockClientsDatabaseAccessor.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockClientsDatabaseAccessor.java
new file mode 100644
index 000000000..b1aeb7cd1
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockClientsDatabaseAccessor.java
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import org.mozilla.gecko.sync.CommandProcessor.Command;
+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.Collection;
+import java.util.List;
+import java.util.Map;
+
+public class MockClientsDatabaseAccessor extends ClientsDatabaseAccessor {
+ public boolean storedRecord = false;
+ public boolean dbWiped = false;
+ public boolean clientsTableWiped = false;
+ public boolean closed = false;
+ public boolean storedArrayList = false;
+ public boolean storedCommand;
+
+ @Override
+ public void store(ClientRecord record) {
+ storedRecord = true;
+ }
+
+ @Override
+ public void store(Collection<ClientRecord> records) {
+ storedArrayList = false;
+ }
+
+ @Override
+ public void store(String accountGUID, Command command) throws NullCursorException {
+ storedCommand = true;
+ }
+
+ @Override
+ public ClientRecord fetchClient(String profileID) throws NullCursorException {
+ return null;
+ }
+
+ @Override
+ public Map<String, ClientRecord> fetchAllClients() throws NullCursorException {
+ return null;
+ }
+
+ @Override
+ public List<Command> fetchCommandsForClient(String accountGUID) throws NullCursorException {
+ return null;
+ }
+
+ @Override
+ public int clientsCount() {
+ return 0;
+ }
+
+ @Override
+ public void wipeDB() {
+ dbWiped = true;
+ }
+
+ @Override
+ public void wipeClientsTable() {
+ clientsTableWiped = true;
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ }
+
+ public void resetVars() {
+ storedRecord = dbWiped = clientsTableWiped = closed = storedArrayList = false;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockGlobalSession.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockGlobalSession.java
new file mode 100644
index 000000000..63afdd1ac
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockGlobalSession.java
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import org.mozilla.gecko.sync.EngineSettings;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.SyncConfigurationException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.stage.CompletedStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+
+import java.io.IOException;
+import java.util.HashMap;
+
+
+public class MockGlobalSession extends MockPrefsGlobalSession {
+
+ public MockGlobalSession(String username, String password, KeyBundle keyBundle, GlobalSessionCallback callback) throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException {
+ this(new SyncConfiguration(username, new BasicAuthHeaderProvider(username, password), new MockSharedPreferences(), keyBundle), callback);
+ }
+
+ public MockGlobalSession(SyncConfiguration config, GlobalSessionCallback callback)
+ throws SyncConfigurationException, IllegalArgumentException, IOException, NonObjectJSONException {
+ super(config, callback, null, null);
+ }
+
+ @Override
+ public boolean isEngineRemotelyEnabled(String engine, EngineSettings engineSettings) {
+ return false;
+ }
+
+ @Override
+ protected void prepareStages() {
+ super.prepareStages();
+ HashMap<Stage, GlobalSyncStage> newStages = new HashMap<Stage, GlobalSyncStage>(this.stages);
+
+ for (Stage stage : this.stages.keySet()) {
+ newStages.put(stage, new MockServerSyncStage());
+ }
+
+ // This signals that the global session is complete.
+ newStages.put(Stage.completed, new CompletedStage());
+
+ this.stages = newStages;
+ }
+
+ public MockGlobalSession withStage(Stage stage, GlobalSyncStage syncStage) {
+ stages.put(stage, syncStage);
+
+ return this;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockPrefsGlobalSession.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockPrefsGlobalSession.java
new file mode 100644
index 000000000..c864cdf80
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockPrefsGlobalSession.java
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.SyncConfigurationException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
+import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+
+import java.io.IOException;
+
+/**
+ * GlobalSession touches the Android prefs system. Stub that out.
+ */
+public class MockPrefsGlobalSession extends GlobalSession {
+
+ public MockSharedPreferences prefs;
+
+ public MockPrefsGlobalSession(
+ SyncConfiguration config, GlobalSessionCallback callback, Context context,
+ ClientsDataDelegate clientsDelegate)
+ throws SyncConfigurationException, IllegalArgumentException, IOException, NonObjectJSONException {
+ super(config, callback, context, clientsDelegate);
+ }
+
+ public static MockPrefsGlobalSession getSession(
+ String username, String password,
+ KeyBundle syncKeyBundle, GlobalSessionCallback callback, Context context,
+ ClientsDataDelegate clientsDelegate)
+ throws SyncConfigurationException, IllegalArgumentException, IOException, NonObjectJSONException {
+ return getSession(username, new BasicAuthHeaderProvider(username, password), null,
+ syncKeyBundle, callback, context, clientsDelegate);
+ }
+
+ public static MockPrefsGlobalSession getSession(
+ String username, AuthHeaderProvider authHeaderProvider, String prefsPath,
+ KeyBundle syncKeyBundle, GlobalSessionCallback callback, Context context,
+ ClientsDataDelegate clientsDelegate)
+ throws SyncConfigurationException, IllegalArgumentException, IOException, NonObjectJSONException {
+
+ final SharedPreferences prefs = new MockSharedPreferences();
+ final SyncConfiguration config = new SyncConfiguration(username, authHeaderProvider, prefs);
+ config.syncKeyBundle = syncKeyBundle;
+ return new MockPrefsGlobalSession(config, callback, context, clientsDelegate);
+ }
+
+ @Override
+ public Context getContext() {
+ return null;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockRecord.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockRecord.java
new file mode 100644
index 000000000..9876b7867
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockRecord.java
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import java.util.Random;
+
+public class MockRecord extends Record {
+ private final int payloadByteCount;
+ public MockRecord(String guid, String collection, long lastModified, boolean deleted) {
+ super(guid, collection, lastModified, deleted);
+ // Payload used to be "foo", so let's not stray too far.
+ // Perhaps some tests "depend" on that payload size.
+ payloadByteCount = 3;
+ }
+
+ public MockRecord(String guid, String collection, long lastModified, boolean deleted, int payloadByteCount) {
+ super(guid, collection, lastModified, deleted);
+ this.payloadByteCount = payloadByteCount;
+ }
+
+ @Override
+ protected void populatePayload(ExtendedJSONObject payload) {
+ }
+
+ @Override
+ protected void initFromPayload(ExtendedJSONObject payload) {
+ }
+
+ @Override
+ public Record copyWithIDs(String guid, long androidID) {
+ MockRecord r = new MockRecord(guid, this.collection, this.lastModified, this.deleted);
+ r.androidID = androidID;
+ return r;
+ }
+
+ @Override
+ public String toJSONString() {
+ // Build up a randomish payload string based on the length we were asked for.
+ final Random random = new Random();
+ final char[] payloadChars = new char[payloadByteCount];
+ for (int i = 0; i < payloadByteCount; i++) {
+ payloadChars[i] = (char) (random.nextInt(26) + 'a');
+ }
+ final String payloadString = new String(payloadChars);
+ return "{\"id\":\"" + guid + "\", \"payload\": \"" + payloadString+ "\"}";
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockServerSyncStage.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockServerSyncStage.java
new file mode 100644
index 000000000..28a4e58b9
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockServerSyncStage.java
@@ -0,0 +1,11 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+public class MockServerSyncStage extends BaseMockServerSyncStage {
+ @Override
+ public void execute() {
+ session.advance();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockSharedPreferences.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockSharedPreferences.java
new file mode 100644
index 000000000..bc49fa7fb
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockSharedPreferences.java
@@ -0,0 +1,137 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A programmable mock content provider.
+ */
+public class MockSharedPreferences implements SharedPreferences, SharedPreferences.Editor {
+ private HashMap<String, Object> mValues;
+ private HashMap<String, Object> mTempValues;
+
+ public MockSharedPreferences() {
+ mValues = new HashMap<String, Object>();
+ mTempValues = new HashMap<String, Object>();
+ }
+
+ public Editor edit() {
+ return this;
+ }
+
+ public boolean contains(String key) {
+ return mValues.containsKey(key);
+ }
+
+ public Map<String, ?> getAll() {
+ return new HashMap<String, Object>(mValues);
+ }
+
+ public boolean getBoolean(String key, boolean defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Boolean)mValues.get(key)).booleanValue();
+ }
+ return defValue;
+ }
+
+ public float getFloat(String key, float defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Float)mValues.get(key)).floatValue();
+ }
+ return defValue;
+ }
+
+ public int getInt(String key, int defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Integer)mValues.get(key)).intValue();
+ }
+ return defValue;
+ }
+
+ public long getLong(String key, long defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Long)mValues.get(key)).longValue();
+ }
+ return defValue;
+ }
+
+ public String getString(String key, String defValue) {
+ if (mValues.containsKey(key))
+ return (String)mValues.get(key);
+ return defValue;
+ }
+
+ @SuppressWarnings("unchecked")
+ public Set<String> getStringSet(String key, Set<String> defValues) {
+ if (mValues.containsKey(key)) {
+ return (Set<String>) mValues.get(key);
+ }
+ return defValues;
+ }
+
+ public void registerOnSharedPreferenceChangeListener(
+ OnSharedPreferenceChangeListener listener) {
+ throw new UnsupportedOperationException();
+ }
+
+ public void unregisterOnSharedPreferenceChangeListener(
+ OnSharedPreferenceChangeListener listener) {
+ throw new UnsupportedOperationException();
+ }
+
+ public Editor putBoolean(String key, boolean value) {
+ mTempValues.put(key, Boolean.valueOf(value));
+ return this;
+ }
+
+ public Editor putFloat(String key, float value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putInt(String key, int value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putLong(String key, long value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putString(String key, String value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putStringSet(String key, Set<String> values) {
+ mTempValues.put(key, values);
+ return this;
+ }
+
+ public Editor remove(String key) {
+ mTempValues.remove(key);
+ return this;
+ }
+
+ public Editor clear() {
+ mTempValues.clear();
+ return this;
+ }
+
+ @SuppressWarnings("unchecked")
+ public boolean commit() {
+ mValues = (HashMap<String, Object>)mTempValues.clone();
+ return true;
+ }
+
+ public void apply() {
+ commit();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/TestRunner.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/TestRunner.java
new file mode 100644
index 000000000..ccb5276ed
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/TestRunner.java
@@ -0,0 +1,125 @@
+/**
+ * The MIT License
+ *
+ * Copyright (c) 2010 Xtreme Labs and Pivotal Labs
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import org.junit.runners.model.InitializationError;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.res.FileFsFile;
+import org.robolectric.res.FsFile;
+import org.robolectric.util.Logger;
+import org.robolectric.util.ReflectionHelpers;
+
+/**
+ * Test runner customized for running unit tests either through the Gradle CLI or
+ * Android Studio. The runner uses the build type and build flavor to compute the
+ * resource, asset, and AndroidManifest paths.
+ *
+ * This test runner requires that you set the 'constants' field on the @Config
+ * annotation (or the org.robolectric.Config.properties file) for your tests.
+ *
+ * This is a modified version of
+ * https://github.com/robolectric/robolectric/blob/8676da2daa4c140679fb5903696b8191415cec8f/robolectric/src/main/java/org/robolectric/RobolectricGradleTestRunner.java
+ * that uses a Gradle `buildConfigField` to find build outputs.
+ * See https://github.com/robolectric/robolectric/issues/1648#issuecomment-113731011.
+ */
+public class TestRunner extends RobolectricTestRunner {
+ private FsFile buildFolder;
+
+ public TestRunner(Class<?> klass) throws InitializationError {
+ super(klass);
+ }
+
+ @Override
+ protected AndroidManifest getAppManifest(Config config) {
+ if (config.constants() == Void.class) {
+ Logger.error("Field 'constants' not specified in @Config annotation");
+ Logger.error("This is required when using RobolectricGradleTestRunner!");
+ throw new RuntimeException("No 'constants' field in @Config annotation!");
+ }
+
+ buildFolder = FileFsFile.from(getBuildDir(config)).join("intermediates");
+
+ final String type = getType(config);
+ final String flavor = getFlavor(config);
+ final String packageName = getPackageName(config);
+
+ final FsFile assets = buildFolder.join("assets", flavor, type);;
+ final FsFile manifest = buildFolder.join("manifests", "full", flavor, type, "AndroidManifest.xml");
+
+ final FsFile res;
+ if (buildFolder.join("res", "merged").exists()) {
+ res = buildFolder.join("res", "merged", flavor, type);
+ } else if(buildFolder.join("res").exists()) {
+ res = buildFolder.join("res", flavor, type);
+ } else {
+ throw new IllegalStateException("No resource folder found");
+ }
+
+ Logger.debug("Robolectric assets directory: " + assets.getPath());
+ Logger.debug(" Robolectric res directory: " + res.getPath());
+ Logger.debug(" Robolectric manifest path: " + manifest.getPath());
+ Logger.debug(" Robolectric package name: " + packageName);
+ return new AndroidManifest(manifest, res, assets, packageName);
+ }
+
+ private static String getType(Config config) {
+ try {
+ return ReflectionHelpers.getStaticField(config.constants(), "BUILD_TYPE");
+ } catch (Throwable e) {
+ return null;
+ }
+ }
+
+ private static String getFlavor(Config config) {
+ try {
+ return ReflectionHelpers.getStaticField(config.constants(), "FLAVOR");
+ } catch (Throwable e) {
+ return null;
+ }
+ }
+
+ private static String getPackageName(Config config) {
+ try {
+ final String packageName = config.packageName();
+ if (packageName != null && !packageName.isEmpty()) {
+ return packageName;
+ } else {
+ return ReflectionHelpers.getStaticField(config.constants(), "APPLICATION_ID");
+ }
+ } catch (Throwable e) {
+ return null;
+ }
+ }
+
+ private String getBuildDir(Config config) {
+ try {
+ return ReflectionHelpers.getStaticField(config.constants(), "BUILD_DIR");
+ } catch (Throwable e) {
+ return null;
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WBORepository.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WBORepository.java
new file mode 100644
index 000000000..672b0a602
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WBORepository.java
@@ -0,0 +1,230 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import android.content.Context;
+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.NoStoreDelegateException;
+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.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.Record;
+
+import java.util.Map.Entry;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class WBORepository extends Repository {
+
+ public class WBORepositoryStats {
+ public long created = -1;
+ public long begun = -1;
+ public long fetchBegan = -1;
+ public long fetchCompleted = -1;
+ public long storeBegan = -1;
+ public long storeCompleted = -1;
+ public long finished = -1;
+ }
+
+ public static final String LOG_TAG = "WBORepository";
+
+ // Access to stats is not guarded.
+ public WBORepositoryStats stats;
+
+ // Whether or not to increment the timestamp of stored records.
+ public final boolean bumpTimestamps;
+
+ public class WBORepositorySession extends StoreTrackingRepositorySession {
+
+ protected WBORepository wboRepository;
+ protected ExecutorService delegateExecutor = Executors.newSingleThreadExecutor();
+ public ConcurrentHashMap<String, Record> wbos;
+
+ public WBORepositorySession(WBORepository repository) {
+ super(repository);
+
+ wboRepository = repository;
+ wbos = new ConcurrentHashMap<String, Record>();
+ stats = new WBORepositoryStats();
+ stats.created = now();
+ }
+
+ @Override
+ protected synchronized void trackGUID(String guid) {
+ if (wboRepository.shouldTrack()) {
+ super.trackGUID(guid);
+ }
+ }
+
+ @Override
+ public void guidsSince(long timestamp,
+ RepositorySessionGuidsSinceDelegate delegate) {
+ throw new RuntimeException("guidsSince not implemented.");
+ }
+
+ @Override
+ public void fetchSince(long timestamp,
+ RepositorySessionFetchRecordsDelegate delegate) {
+ long fetchBegan = now();
+ stats.fetchBegan = fetchBegan;
+ RecordFilter filter = storeTracker.getFilter();
+
+ for (Entry<String, Record> entry : wbos.entrySet()) {
+ Record record = entry.getValue();
+ if (record.lastModified >= timestamp) {
+ if (filter != null &&
+ filter.excludeRecord(record)) {
+ Logger.debug(LOG_TAG, "Excluding record " + record.guid);
+ continue;
+ }
+ delegate.deferredFetchDelegate(delegateExecutor).onFetchedRecord(record);
+ }
+ }
+ long fetchCompleted = now();
+ stats.fetchCompleted = fetchCompleted;
+ delegate.deferredFetchDelegate(delegateExecutor).onFetchCompleted(fetchCompleted);
+ }
+
+ @Override
+ public void fetch(final String[] guids,
+ final RepositorySessionFetchRecordsDelegate delegate) {
+ long fetchBegan = now();
+ stats.fetchBegan = fetchBegan;
+ for (String guid : guids) {
+ if (wbos.containsKey(guid)) {
+ delegate.deferredFetchDelegate(delegateExecutor).onFetchedRecord(wbos.get(guid));
+ }
+ }
+ long fetchCompleted = now();
+ stats.fetchCompleted = fetchCompleted;
+ delegate.deferredFetchDelegate(delegateExecutor).onFetchCompleted(fetchCompleted);
+ }
+
+ @Override
+ public void fetchAll(final RepositorySessionFetchRecordsDelegate delegate) {
+ long fetchBegan = now();
+ stats.fetchBegan = fetchBegan;
+ for (Entry<String, Record> entry : wbos.entrySet()) {
+ Record record = entry.getValue();
+ delegate.deferredFetchDelegate(delegateExecutor).onFetchedRecord(record);
+ }
+ long fetchCompleted = now();
+ stats.fetchCompleted = fetchCompleted;
+ delegate.deferredFetchDelegate(delegateExecutor).onFetchCompleted(fetchCompleted);
+ }
+
+ @Override
+ public void store(final Record record) throws NoStoreDelegateException {
+ if (delegate == null) {
+ throw new NoStoreDelegateException();
+ }
+ final long now = now();
+ if (stats.storeBegan < 0) {
+ stats.storeBegan = now;
+ }
+ Record existing = wbos.get(record.guid);
+ Logger.debug(LOG_TAG, "Existing record is " + (existing == null ? "<null>" : (existing.guid + ", " + existing)));
+ if (existing != null &&
+ existing.lastModified > record.lastModified) {
+ Logger.debug(LOG_TAG, "Local record is newer. Not storing.");
+ delegate.deferredStoreDelegate(delegateExecutor).onRecordStoreSucceeded(record.guid);
+ return;
+ }
+ if (existing != null) {
+ Logger.debug(LOG_TAG, "Replacing local record.");
+ }
+
+ // Store a copy of the record with an updated modified time.
+ Record toStore = record.copyWithIDs(record.guid, record.androidID);
+ if (bumpTimestamps) {
+ toStore.lastModified = now;
+ }
+ wbos.put(record.guid, toStore);
+
+ trackRecord(toStore);
+ delegate.deferredStoreDelegate(delegateExecutor).onRecordStoreSucceeded(record.guid);
+ }
+
+ @Override
+ public void wipe(final RepositorySessionWipeDelegate delegate) {
+ if (!isActive()) {
+ delegate.onWipeFailed(new InactiveSessionException(null));
+ return;
+ }
+
+ Logger.info(LOG_TAG, "Wiping WBORepositorySession.");
+ this.wbos = new ConcurrentHashMap<String, Record>();
+
+ // Wipe immediately for the convenience of test code.
+ wboRepository.wbos = new ConcurrentHashMap<String, Record>();
+ delegate.deferredWipeDelegate(delegateExecutor).onWipeSucceeded();
+ }
+
+ @Override
+ public void finish(RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
+ Logger.info(LOG_TAG, "Finishing WBORepositorySession: handing back " + this.wbos.size() + " WBOs.");
+ wboRepository.wbos = this.wbos;
+ stats.finished = now();
+ super.finish(delegate);
+ }
+
+ @Override
+ public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
+ this.wbos = wboRepository.cloneWBOs();
+ stats.begun = now();
+ super.begin(delegate);
+ }
+
+ @Override
+ public void storeDone(long end) {
+ // TODO: this is not guaranteed to be called after all of the record
+ // store callbacks have completed!
+ if (stats.storeBegan < 0) {
+ stats.storeBegan = end;
+ }
+ stats.storeCompleted = end;
+ delegate.deferredStoreDelegate(delegateExecutor).onStoreCompleted(end);
+ }
+ }
+
+ public ConcurrentHashMap<String, Record> wbos;
+
+ public WBORepository(boolean bumpTimestamps) {
+ super();
+ this.bumpTimestamps = bumpTimestamps;
+ this.wbos = new ConcurrentHashMap<String, Record>();
+ }
+
+ public WBORepository() {
+ this(false);
+ }
+
+ public synchronized boolean shouldTrack() {
+ return false;
+ }
+
+ @Override
+ public void createSession(RepositorySessionCreationDelegate delegate,
+ Context context) {
+ delegate.deferredCreationDelegate().onSessionCreated(new WBORepositorySession(this));
+ }
+
+ public ConcurrentHashMap<String, Record> cloneWBOs() {
+ ConcurrentHashMap<String, Record> out = new ConcurrentHashMap<String, Record>();
+ for (Entry<String, Record> entry : wbos.entrySet()) {
+ out.put(entry.getKey(), entry.getValue()); // Assume that records are
+ // immutable.
+ }
+ return out;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WaitHelper.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WaitHelper.java
new file mode 100644
index 000000000..dad748df1
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/WaitHelper.java
@@ -0,0 +1,172 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.testhelpers;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Implements waiting for asynchronous test events.
+ *
+ * Call WaitHelper.getTestWaiter() to get the unique instance.
+ *
+ * Call performWait(runnable) to execute runnable synchronously.
+ * runnable *must* call performNotify() on all exit paths to signal to
+ * the TestWaiter that the runnable has completed.
+ *
+ * @author rnewman
+ * @author nalexander
+ */
+public class WaitHelper {
+
+ public static final String LOG_TAG = "WaitHelper";
+
+ public static class Result {
+ public Throwable error;
+ public Result() {
+ error = null;
+ }
+
+ public Result(Throwable error) {
+ this.error = error;
+ }
+ }
+
+ public static abstract class WaitHelperError extends Error {
+ private static final long serialVersionUID = 7074690961681883619L;
+ }
+
+ /**
+ * Immutable.
+ *
+ * @author rnewman
+ */
+ public static class TimeoutError extends WaitHelperError {
+ private static final long serialVersionUID = 8591672555848651736L;
+ public final int waitTimeInMillis;
+
+ public TimeoutError(int waitTimeInMillis) {
+ this.waitTimeInMillis = waitTimeInMillis;
+ }
+ }
+
+ public static class MultipleNotificationsError extends WaitHelperError {
+ private static final long serialVersionUID = -9072736521571635495L;
+ }
+
+ public static class InterruptedError extends WaitHelperError {
+ private static final long serialVersionUID = 8383948170038639308L;
+ }
+
+ public static class InnerError extends WaitHelperError {
+ private static final long serialVersionUID = 3008502618576773778L;
+ public Throwable innerError;
+
+ public InnerError(Throwable e) {
+ innerError = e;
+ if (e != null) {
+ // Eclipse prints the stack trace of the cause.
+ this.initCause(e);
+ }
+ }
+ }
+
+ public BlockingQueue<Result> queue = new ArrayBlockingQueue<Result>(1);
+
+ /**
+ * How long performWait should wait for, in milliseconds, with the
+ * convention that a negative value means "wait forever".
+ */
+ public static int defaultWaitTimeoutInMillis = -1;
+
+ public void performWait(Runnable action) throws WaitHelperError {
+ this.performWait(defaultWaitTimeoutInMillis, action);
+ }
+
+ public void performWait(int waitTimeoutInMillis, Runnable action) throws WaitHelperError {
+ Logger.debug(LOG_TAG, "performWait called.");
+
+ Result result = null;
+
+ try {
+ if (action != null) {
+ try {
+ action.run();
+ Logger.debug(LOG_TAG, "Action done.");
+ } catch (Exception ex) {
+ Logger.debug(LOG_TAG, "Performing action threw: " + ex.getMessage());
+ throw new InnerError(ex);
+ }
+ }
+
+ if (waitTimeoutInMillis < 0) {
+ result = queue.take();
+ } else {
+ result = queue.poll(waitTimeoutInMillis, TimeUnit.MILLISECONDS);
+ }
+ Logger.debug(LOG_TAG, "Got result from queue: " + result);
+ } catch (InterruptedException e) {
+ // We were interrupted.
+ Logger.debug(LOG_TAG, "performNotify interrupted with InterruptedException " + e);
+ final InterruptedError interruptedError = new InterruptedError();
+ interruptedError.initCause(e);
+ throw interruptedError;
+ }
+
+ if (result == null) {
+ // We timed out.
+ throw new TimeoutError(waitTimeoutInMillis);
+ } else if (result.error != null) {
+ Logger.debug(LOG_TAG, "Notified with error: " + result.error.getMessage());
+
+ // Rethrow any assertion with which we were notified.
+ InnerError innerError = new InnerError(result.error);
+ throw innerError;
+ }
+ // Success!
+ }
+
+ public void performNotify(final Throwable e) {
+ if (e != null) {
+ Logger.debug(LOG_TAG, "performNotify called with Throwable: " + e.getMessage());
+ } else {
+ Logger.debug(LOG_TAG, "performNotify called.");
+ }
+
+ if (!queue.offer(new Result(e))) {
+ // This could happen if performNotify is called multiple times (which is an error).
+ throw new MultipleNotificationsError();
+ }
+ }
+
+ public void performNotify() {
+ this.performNotify(null);
+ }
+
+ public static Runnable onThreadRunnable(final Runnable r) {
+ return new Runnable() {
+ @Override
+ public void run() {
+ new Thread(r).start();
+ }
+ };
+ }
+
+ private static WaitHelper singleWaiter = new WaitHelper();
+ public static WaitHelper getTestWaiter() {
+ return singleWaiter;
+ }
+
+ public static void resetTestWaiter() {
+ singleWaiter = new WaitHelper();
+ }
+
+ public boolean isIdle() {
+ return queue.isEmpty();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestASNUtils.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestASNUtils.java
new file mode 100644
index 000000000..f0b1f98b5
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestASNUtils.java
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.browserid.test;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.browserid.ASNUtils;
+import org.mozilla.gecko.sync.Utils;
+
+import java.math.BigInteger;
+
+@RunWith(TestRunner.class)
+public class TestASNUtils {
+ public void doTestEncodeDecodeArrays(int length1, int length2) {
+ if (4 + length1 + length2 > 127) {
+ throw new IllegalArgumentException("Total length must be < 128 - 4.");
+ }
+ byte[] first = Utils.generateRandomBytes(length1);
+ byte[] second = Utils.generateRandomBytes(length2);
+ byte[] encoded = ASNUtils.encodeTwoArraysToASN1(first, second);
+ byte[][] arrays = ASNUtils.decodeTwoArraysFromASN1(encoded);
+ Assert.assertArrayEquals(first, arrays[0]);
+ Assert.assertArrayEquals(second, arrays[1]);
+ }
+
+ @Test
+ public void testEncodeDecodeArrays() {
+ doTestEncodeDecodeArrays(0, 0);
+ doTestEncodeDecodeArrays(0, 10);
+ doTestEncodeDecodeArrays(10, 0);
+ doTestEncodeDecodeArrays(10, 10);
+ }
+
+ @Test
+ public void testEncodeDecodeRandomSizeArrays() {
+ for (int i = 0; i < 10; i++) {
+ int length1 = Utils.generateBigIntegerLessThan(BigInteger.valueOf(50)).intValue() + 10;
+ int length2 = Utils.generateBigIntegerLessThan(BigInteger.valueOf(50)).intValue() + 10;
+ doTestEncodeDecodeArrays(length1, length2);
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestDSACryptoImplementation.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestDSACryptoImplementation.java
new file mode 100644
index 000000000..62427e5e1
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestDSACryptoImplementation.java
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.browserid.test;
+
+import junit.framework.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.DSACryptoImplementation;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+import java.math.BigInteger;
+
+@RunWith(TestRunner.class)
+public class TestDSACryptoImplementation {
+ @Test
+ public void testToJSONObject() throws Exception {
+ BigInteger p = new BigInteger("fca682ce8e12caba26efccf7110e526db078b05edecbcd1eb4a208f3ae1617ae01f35b91a47e6df63413c5e12ed0899bcd132acd50d99151bdc43ee737592e17", 16);
+ BigInteger q = new BigInteger("962eddcc369cba8ebb260ee6b6a126d9346e38c5", 16);
+ BigInteger g = new BigInteger("678471b27a9cf44ee91a49c5147db1a9aaf244f05a434d6486931d2d14271b9e35030b71fd73da179069b32e2935630e1c2062354d0da20a6c416e50be794ca4", 16);
+ BigInteger x = new BigInteger("9516d860392003db5a4f168444903265467614db", 16);
+ BigInteger y = new BigInteger("455152a0e499f5c9d11f9f1868c8b868b1443ca853843226a5a9552dd909b4bdba879acc504acb690df0348d60e63ea37e8c7f075302e0df5bcdc76a383888a0", 16);
+
+ BrowserIDKeyPair keyPair = new BrowserIDKeyPair(
+ DSACryptoImplementation.createPrivateKey(x, p, q, g),
+ DSACryptoImplementation.createPublicKey(y, p, q, g));
+
+ ExtendedJSONObject o = new ExtendedJSONObject("{\"publicKey\":{\"g\":\"678471b27a9cf44ee91a49c5147db1a9aaf244f05a434d6486931d2d14271b9e35030b71fd73da179069b32e2935630e1c2062354d0da20a6c416e50be794ca4\",\"q\":\"962eddcc369cba8ebb260ee6b6a126d9346e38c5\",\"p\":\"fca682ce8e12caba26efccf7110e526db078b05edecbcd1eb4a208f3ae1617ae01f35b91a47e6df63413c5e12ed0899bcd132acd50d99151bdc43ee737592e17\",\"y\":\"455152a0e499f5c9d11f9f1868c8b868b1443ca853843226a5a9552dd909b4bdba879acc504acb690df0348d60e63ea37e8c7f075302e0df5bcdc76a383888a0\",\"algorithm\":\"DS\"},\"privateKey\":{\"g\":\"678471b27a9cf44ee91a49c5147db1a9aaf244f05a434d6486931d2d14271b9e35030b71fd73da179069b32e2935630e1c2062354d0da20a6c416e50be794ca4\",\"q\":\"962eddcc369cba8ebb260ee6b6a126d9346e38c5\",\"p\":\"fca682ce8e12caba26efccf7110e526db078b05edecbcd1eb4a208f3ae1617ae01f35b91a47e6df63413c5e12ed0899bcd132acd50d99151bdc43ee737592e17\",\"x\":\"9516d860392003db5a4f168444903265467614db\",\"algorithm\":\"DS\"}}");
+ Assert.assertEquals(o.getObject("privateKey"), keyPair.toJSONObject().getObject("privateKey"));
+ Assert.assertEquals(o.getObject("publicKey"), keyPair.toJSONObject().getObject("publicKey"));
+ }
+
+ @Test
+ public void testFromJSONObject() throws Exception {
+ BigInteger p = new BigInteger("fca682ce8e12caba26efccf7110e526db078b05edecbcd1eb4a208f3ae1617ae01f35b91a47e6df63413c5e12ed0899bcd132acd50d99151bdc43ee737592e17", 16);
+ BigInteger q = new BigInteger("962eddcc369cba8ebb260ee6b6a126d9346e38c5", 16);
+ BigInteger g = new BigInteger("678471b27a9cf44ee91a49c5147db1a9aaf244f05a434d6486931d2d14271b9e35030b71fd73da179069b32e2935630e1c2062354d0da20a6c416e50be794ca4", 16);
+ BigInteger x = new BigInteger("9516d860392003db5a4f168444903265467614db", 16);
+ BigInteger y = new BigInteger("455152a0e499f5c9d11f9f1868c8b868b1443ca853843226a5a9552dd909b4bdba879acc504acb690df0348d60e63ea37e8c7f075302e0df5bcdc76a383888a0", 16);
+
+ BrowserIDKeyPair keyPair = new BrowserIDKeyPair(
+ DSACryptoImplementation.createPrivateKey(x, p, q, g),
+ DSACryptoImplementation.createPublicKey(y, p, q, g));
+
+ ExtendedJSONObject o = new ExtendedJSONObject("{\"publicKey\":{\"g\":\"678471b27a9cf44ee91a49c5147db1a9aaf244f05a434d6486931d2d14271b9e35030b71fd73da179069b32e2935630e1c2062354d0da20a6c416e50be794ca4\",\"q\":\"962eddcc369cba8ebb260ee6b6a126d9346e38c5\",\"p\":\"fca682ce8e12caba26efccf7110e526db078b05edecbcd1eb4a208f3ae1617ae01f35b91a47e6df63413c5e12ed0899bcd132acd50d99151bdc43ee737592e17\",\"y\":\"455152a0e499f5c9d11f9f1868c8b868b1443ca853843226a5a9552dd909b4bdba879acc504acb690df0348d60e63ea37e8c7f075302e0df5bcdc76a383888a0\",\"algorithm\":\"DS\"},\"privateKey\":{\"g\":\"678471b27a9cf44ee91a49c5147db1a9aaf244f05a434d6486931d2d14271b9e35030b71fd73da179069b32e2935630e1c2062354d0da20a6c416e50be794ca4\",\"q\":\"962eddcc369cba8ebb260ee6b6a126d9346e38c5\",\"p\":\"fca682ce8e12caba26efccf7110e526db078b05edecbcd1eb4a208f3ae1617ae01f35b91a47e6df63413c5e12ed0899bcd132acd50d99151bdc43ee737592e17\",\"x\":\"9516d860392003db5a4f168444903265467614db\",\"algorithm\":\"DS\"}}");
+
+ Assert.assertEquals(keyPair.getPublic().toJSONObject(), DSACryptoImplementation.createPublicKey(o.getObject("publicKey")).toJSONObject());
+ Assert.assertEquals(keyPair.getPrivate().toJSONObject(), DSACryptoImplementation.createPrivateKey(o.getObject("privateKey")).toJSONObject());
+ }
+
+ @Test
+ public void testRoundTrip() throws Exception {
+ BrowserIDKeyPair keyPair = DSACryptoImplementation.generateKeyPair(512);
+ ExtendedJSONObject o = keyPair.toJSONObject();
+ BrowserIDKeyPair keyPair2 = DSACryptoImplementation.fromJSONObject(o);
+ Assert.assertEquals(o, keyPair2.toJSONObject());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestJSONWebTokenUtils.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestJSONWebTokenUtils.java
new file mode 100644
index 000000000..7e1f9287e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestJSONWebTokenUtils.java
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.browserid.test;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.DSACryptoImplementation;
+import org.mozilla.gecko.browserid.JSONWebTokenUtils;
+import org.mozilla.gecko.browserid.RSACryptoImplementation;
+import org.mozilla.gecko.browserid.SigningPrivateKey;
+import org.mozilla.gecko.browserid.VerifyingPublicKey;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+import java.math.BigInteger;
+import java.security.GeneralSecurityException;
+
+@RunWith(TestRunner.class)
+public class TestJSONWebTokenUtils {
+ public void doTestEncodeDecode(BrowserIDKeyPair keyPair) throws Exception {
+ SigningPrivateKey privateKey = keyPair.getPrivate();
+ VerifyingPublicKey publicKey = keyPair.getPublic();
+
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put("key", "value");
+
+ String token = JSONWebTokenUtils.encode(o.toJSONString(), privateKey);
+ Assert.assertNotNull(token);
+
+ String payload = JSONWebTokenUtils.decode(token, publicKey);
+ Assert.assertEquals(o.toJSONString(), payload);
+
+ try {
+ JSONWebTokenUtils.decode(token + "x", publicKey);
+ Assert.fail("Expected exception.");
+ } catch (GeneralSecurityException e) {
+ // Do nothing.
+ }
+ }
+
+ @Test
+ public void testEncodeDecodeSuccessRSA() throws Exception {
+ doTestEncodeDecode(RSACryptoImplementation.generateKeyPair(1024));
+ doTestEncodeDecode(RSACryptoImplementation.generateKeyPair(2048));
+ }
+
+ @Test
+ public void testEncodeDecodeSuccessDSA() throws Exception {
+ doTestEncodeDecode(DSACryptoImplementation.generateKeyPair(512));
+ doTestEncodeDecode(DSACryptoImplementation.generateKeyPair(1024));
+ }
+
+ public static String TEST_ASSERTION_ISSUER = "127.0.0.1";
+ public static String TEST_AUDIENCE = "http://localhost:8080";
+
+ @Test
+ public void testRSAGeneration() throws Exception {
+ // This test uses (now out-dated) MockMyID RSA data but doesn't rely on this
+ // data actually being MockMyID's data.
+ final BigInteger MOCKMYID_MODULUS = new BigInteger("15498874758090276039465094105837231567265546373975960480941122651107772824121527483107402353899846252489837024870191707394743196399582959425513904762996756672089693541009892030848825079649783086005554442490232900875792851786203948088457942416978976455297428077460890650409549242124655536986141363719589882160081480785048965686285142002320767066674879737238012064156675899512503143225481933864507793118457805792064445502834162315532113963746801770187685650408560424682654937744713813773896962263709692724630650952159596951348264005004375017610441835956073275708740239518011400991972811669493356682993446554779893834303");
+ final BigInteger MOCKMYID_PUBLIC_EXPONENT = new BigInteger("65537");
+ final BigInteger MOCKMYID_PRIVATE_EXPONENT = new BigInteger("6539906961872354450087244036236367269804254381890095841127085551577495913426869112377010004955160417265879626558436936025363204803913318582680951558904318308893730033158178650549970379367915856087364428530828396795995781364659413467784853435450762392157026962694408807947047846891301466649598749901605789115278274397848888140105306063608217776127549926721544215720872305194645129403056801987422794114703255989202755511523434098625000826968430077091984351410839837395828971692109391386427709263149504336916566097901771762648090880994773325283207496645630792248007805177873532441314470502254528486411726581424522838833");
+
+ BigInteger n = new BigInteger("20332459213245328760269530796942625317006933400814022542511832260333163206808672913301254872114045771215470352093046136365629411384688395020388553744886954869033696089099714200452682590914843971683468562019706059388121176435204818734091361033445697933682779095713376909412972373727850278295874361806633955236862180792787906413536305117030045164276955491725646610368132167655556353974515423042221261732084368978523747789654468953860772774078384556028728800902433401131226904244661160767916883680495122225202542023841606998867411022088440946301191503335932960267228470933599974787151449279465703844493353175088719018221");
+ BigInteger e = new BigInteger("65537");
+ BigInteger d = new BigInteger("9362542596354998418106014928820888151984912891492829581578681873633736656469965533631464203894863562319612803232737938923691416707617473868582415657005943574434271946791143554652502483003923911339605326222297167404896789026986450703532494518628015811567189641735787240372075015553947628033216297520493759267733018808392882741098489889488442349031883643894014316243251108104684754879103107764521172490019661792943030921873284592436328217485953770574054344056638447333651425231219150676837203185544359148474983670261712939626697233692596362322419559401320065488125670905499610998631622562652935873085671353890279911361");
+
+ long iat = 1352995809210L;
+ long dur = 60 * 60 * 1000;
+ long exp = iat + dur;
+
+ VerifyingPublicKey mockMyIdPublicKey = RSACryptoImplementation.createPublicKey(MOCKMYID_MODULUS, MOCKMYID_PUBLIC_EXPONENT);;
+ SigningPrivateKey mockMyIdPrivateKey = RSACryptoImplementation.createPrivateKey(MOCKMYID_MODULUS, MOCKMYID_PRIVATE_EXPONENT);
+ VerifyingPublicKey publicKeyToSign = RSACryptoImplementation.createPublicKey(n, e);
+ SigningPrivateKey privateKeyToSignWith = RSACryptoImplementation.createPrivateKey(n, d);
+
+ String certificate = JSONWebTokenUtils.createCertificate(publicKeyToSign, "test@mockmyid.com", "mockmyid.com", iat, exp, mockMyIdPrivateKey);
+ String assertion = JSONWebTokenUtils.createAssertion(privateKeyToSignWith, certificate, TEST_AUDIENCE, TEST_ASSERTION_ISSUER, iat, exp);
+ String payload = JSONWebTokenUtils.decode(certificate, mockMyIdPublicKey);
+
+ String EXPECTED_PAYLOAD = "{\"exp\":1352999409210,\"iat\":1352995809210,\"iss\":\"mockmyid.com\",\"principal\":{\"email\":\"test@mockmyid.com\"},\"public-key\":{\"e\":\"65537\",\"n\":\"20332459213245328760269530796942625317006933400814022542511832260333163206808672913301254872114045771215470352093046136365629411384688395020388553744886954869033696089099714200452682590914843971683468562019706059388121176435204818734091361033445697933682779095713376909412972373727850278295874361806633955236862180792787906413536305117030045164276955491725646610368132167655556353974515423042221261732084368978523747789654468953860772774078384556028728800902433401131226904244661160767916883680495122225202542023841606998867411022088440946301191503335932960267228470933599974787151449279465703844493353175088719018221\",\"algorithm\":\"RS\"}}";
+ Assert.assertEquals(EXPECTED_PAYLOAD, payload);
+
+ // Really(!) brittle tests below. The RSA signature algorithm is deterministic, so we can test the actual signature.
+ String EXPECTED_CERTIFICATE = "eyJhbGciOiJSUzI1NiJ9.eyJleHAiOjEzNTI5OTk0MDkyMTAsImlhdCI6MTM1Mjk5NTgwOTIxMCwiaXNzIjoibW9ja215aWQuY29tIiwicHJpbmNpcGFsIjp7ImVtYWlsIjoidGVzdEBtb2NrbXlpZC5jb20ifSwicHVibGljLWtleSI6eyJlIjoiNjU1MzciLCJuIjoiMjAzMzI0NTkyMTMyNDUzMjg3NjAyNjk1MzA3OTY5NDI2MjUzMTcwMDY5MzM0MDA4MTQwMjI1NDI1MTE4MzIyNjAzMzMxNjMyMDY4MDg2NzI5MTMzMDEyNTQ4NzIxMTQwNDU3NzEyMTU0NzAzNTIwOTMwNDYxMzYzNjU2Mjk0MTEzODQ2ODgzOTUwMjAzODg1NTM3NDQ4ODY5NTQ4NjkwMzM2OTYwODkwOTk3MTQyMDA0NTI2ODI1OTA5MTQ4NDM5NzE2ODM0Njg1NjIwMTk3MDYwNTkzODgxMjExNzY0MzUyMDQ4MTg3MzQwOTEzNjEwMzM0NDU2OTc5MzM2ODI3NzkwOTU3MTMzNzY5MDk0MTI5NzIzNzM3Mjc4NTAyNzgyOTU4NzQzNjE4MDY2MzM5NTUyMzY4NjIxODA3OTI3ODc5MDY0MTM1MzYzMDUxMTcwMzAwNDUxNjQyNzY5NTU0OTE3MjU2NDY2MTAzNjgxMzIxNjc2NTU1NTYzNTM5NzQ1MTU0MjMwNDIyMjEyNjE3MzIwODQzNjg5Nzg1MjM3NDc3ODk2NTQ0Njg5NTM4NjA3NzI3NzQwNzgzODQ1NTYwMjg3Mjg4MDA5MDI0MzM0MDExMzEyMjY5MDQyNDQ2NjExNjA3Njc5MTY4ODM2ODA0OTUxMjIyMjUyMDI1NDIwMjM4NDE2MDY5OTg4Njc0MTEwMjIwODg0NDA5NDYzMDExOTE1MDMzMzU5MzI5NjAyNjcyMjg0NzA5MzM1OTk5NzQ3ODcxNTE0NDkyNzk0NjU3MDM4NDQ0OTMzNTMxNzUwODg3MTkwMTgyMjEiLCJhbGdvcml0aG0iOiJSUyJ9fQ.ZgT0ezITaE6rRQCxEA6OHkjwAsFdE-R8943UEmiCvKKpsbxlSlI1Iya1Oho2wrhet5bjBGM77EffzC2YwzD5qa7SrVpNwSCIW6AwnlJ6YePoNblkn0y7NQ_qThvLoaP4Vlk_XM0LbK_QPHqaWU7ldm8LF5Zp4oHgayMP4YhiyKYS2TwWWcvswT2g9IhU6YdYcF0TwT2YkJ4t3h7_sVn-OmQQu4k1KKGFLpT6HOj2EGaKmw-mzayHL0r7L3-5g_7Q83RMBe_k_4YeLG8InxO3M3GreqcaImv4XO5D-C__txfFuaLJjTzKBLrIIosckaNwp4JmN1Nf8x9t5RXHLCsrjw";
+ Assert.assertEquals(EXPECTED_CERTIFICATE, certificate);
+
+ String EXPECTED_ASSERTION = "eyJhbGciOiJSUzI1NiJ9.eyJleHAiOjEzNTI5OTk0MDkyMTAsImlhdCI6MTM1Mjk5NTgwOTIxMCwiaXNzIjoibW9ja215aWQuY29tIiwicHJpbmNpcGFsIjp7ImVtYWlsIjoidGVzdEBtb2NrbXlpZC5jb20ifSwicHVibGljLWtleSI6eyJlIjoiNjU1MzciLCJuIjoiMjAzMzI0NTkyMTMyNDUzMjg3NjAyNjk1MzA3OTY5NDI2MjUzMTcwMDY5MzM0MDA4MTQwMjI1NDI1MTE4MzIyNjAzMzMxNjMyMDY4MDg2NzI5MTMzMDEyNTQ4NzIxMTQwNDU3NzEyMTU0NzAzNTIwOTMwNDYxMzYzNjU2Mjk0MTEzODQ2ODgzOTUwMjAzODg1NTM3NDQ4ODY5NTQ4NjkwMzM2OTYwODkwOTk3MTQyMDA0NTI2ODI1OTA5MTQ4NDM5NzE2ODM0Njg1NjIwMTk3MDYwNTkzODgxMjExNzY0MzUyMDQ4MTg3MzQwOTEzNjEwMzM0NDU2OTc5MzM2ODI3NzkwOTU3MTMzNzY5MDk0MTI5NzIzNzM3Mjc4NTAyNzgyOTU4NzQzNjE4MDY2MzM5NTUyMzY4NjIxODA3OTI3ODc5MDY0MTM1MzYzMDUxMTcwMzAwNDUxNjQyNzY5NTU0OTE3MjU2NDY2MTAzNjgxMzIxNjc2NTU1NTYzNTM5NzQ1MTU0MjMwNDIyMjEyNjE3MzIwODQzNjg5Nzg1MjM3NDc3ODk2NTQ0Njg5NTM4NjA3NzI3NzQwNzgzODQ1NTYwMjg3Mjg4MDA5MDI0MzM0MDExMzEyMjY5MDQyNDQ2NjExNjA3Njc5MTY4ODM2ODA0OTUxMjIyMjUyMDI1NDIwMjM4NDE2MDY5OTg4Njc0MTEwMjIwODg0NDA5NDYzMDExOTE1MDMzMzU5MzI5NjAyNjcyMjg0NzA5MzM1OTk5NzQ3ODcxNTE0NDkyNzk0NjU3MDM4NDQ0OTMzNTMxNzUwODg3MTkwMTgyMjEiLCJhbGdvcml0aG0iOiJSUyJ9fQ.ZgT0ezITaE6rRQCxEA6OHkjwAsFdE-R8943UEmiCvKKpsbxlSlI1Iya1Oho2wrhet5bjBGM77EffzC2YwzD5qa7SrVpNwSCIW6AwnlJ6YePoNblkn0y7NQ_qThvLoaP4Vlk_XM0LbK_QPHqaWU7ldm8LF5Zp4oHgayMP4YhiyKYS2TwWWcvswT2g9IhU6YdYcF0TwT2YkJ4t3h7_sVn-OmQQu4k1KKGFLpT6HOj2EGaKmw-mzayHL0r7L3-5g_7Q83RMBe_k_4YeLG8InxO3M3GreqcaImv4XO5D-C__txfFuaLJjTzKBLrIIosckaNwp4JmN1Nf8x9t5RXHLCsrjw~eyJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJodHRwOlwvXC9sb2NhbGhvc3Q6ODA4MCIsImV4cCI6MTM1Mjk5OTQwOTIxMCwiaWF0IjoxMzUyOTk1ODA5MjEwLCJpc3MiOiIxMjcuMC4wLjEifQ.gj5Q9KXR_mPEltn3SXKAjIHMOpQq0FP6NdPOB-Zu149LKhQrfXS90woVJYg8WpaasmiS6gjBFni3urq3adPktzw4RoMm1qVMvSRXXIRZzgsV_vHlSenIY0KlAk4140pAlAPcdJhB2bvKUPPDq0TLzlWHgQpheAAFMGPY1OGgwgHtsCQC_vyE2wFi8M58IGYQ-05KmWc6Zo33CJG6LjVvkTPvPTEzQKFYKwDQGc4NTkqZbCNZE6iRq4mlX9LGFddzEDiSUDmS53SwR4nfFzPQE6Q1xnU4a_BLhfNpdfOc-uHGoJGbm0ZJpLdKf7zadp34ImFA9IUBhjegingZhm2i5g";
+ Assert.assertEquals(EXPECTED_ASSERTION, assertion);
+ }
+
+ @Test
+ public void testDSAGeneration() throws Exception {
+ // This test uses MockMyID DSA data but doesn't rely on this data actually
+ // being MockMyID's data.
+ final BigInteger MOCKMYID_x = new BigInteger("385cb3509f086e110c5e24bdd395a84b335a09ae", 16);
+ final BigInteger MOCKMYID_y = new BigInteger("738ec929b559b604a232a9b55a5295afc368063bb9c20fac4e53a74970a4db7956d48e4c7ed523405f629b4cc83062f13029c4d615bbacb8b97f5e56f0c7ac9bc1d4e23809889fa061425c984061fca1826040c399715ce7ed385c4dd0d402256912451e03452d3c961614eb458f188e3e8d2782916c43dbe2e571251ce38262", 16);
+ final BigInteger MOCKMYID_p = new BigInteger("ff600483db6abfc5b45eab78594b3533d550d9f1bf2a992a7a8daa6dc34f8045ad4e6e0c429d334eeeaaefd7e23d4810be00e4cc1492cba325ba81ff2d5a5b305a8d17eb3bf4a06a349d392e00d329744a5179380344e82a18c47933438f891e22aeef812d69c8f75e326cb70ea000c3f776dfdbd604638c2ef717fc26d02e17", 16);
+ final BigInteger MOCKMYID_q = new BigInteger("e21e04f911d1ed7991008ecaab3bf775984309c3", 16);
+ final BigInteger MOCKMYID_g = new BigInteger("c52a4a0ff3b7e61fdf1867ce84138369a6154f4afa92966e3c827e25cfa6cf508b90e5de419e1337e07a2e9e2a3cd5dea704d175f8ebf6af397d69e110b96afb17c7a03259329e4829b0d03bbc7896b15b4ade53e130858cc34d96269aa89041f409136c7242a38895c9d5bccad4f389af1d7a4bd1398bd072dffa896233397a", 16);
+
+ BigInteger g = new BigInteger("f7e1a085d69b3ddecbbcab5c36b857b97994afbbfa3aea82f9574c0b3d0782675159578ebad4594fe67107108180b449167123e84c281613b7cf09328cc8a6e13c167a8b547c8d28e0a3ae1e2bb3a675916ea37f0bfa213562f1fb627a01243bcca4f1bea8519089a883dfe15ae59f06928b665e807b552564014c3bfecf492a", 16);
+ BigInteger q = new BigInteger("9760508f15230bccb292b982a2eb840bf0581cf5", 16);
+ BigInteger p = new BigInteger("fd7f53811d75122952df4a9c2eece4e7f611b7523cef4400c31e3f80b6512669455d402251fb593d8d58fabfc5f5ba30f6cb9b556cd7813b801d346ff26660b76b9950a5a49f9fe8047b1022c24fbba9d7feb7c61bf83b57e7c6a8a6150f04fb83f6d3c51ec3023554135a169132f675f3ae2b61d72aeff22203199dd14801c7", 16);
+ BigInteger x = new BigInteger("b137fc5b8faaa53b170563eb03c18b46b657bb6", 16);
+ BigInteger y = new BigInteger("ea809be508bc94485553efac8ef2a8debdcdb3545ce433e8bd5889ec9d0880a13b2a8af35451161e58229d1e2be69e74a7251465a394913e8e64b0c33fde39a637b6047d7370178cf4404c0a7b4c2ed31d9cfe03ab79dbcc64667e6e7bc244eb1c127c28d725db94aff29b858bdb636f1307bdf48b3c91f387c2ab588086b6c8", 16);
+
+ long iat = 1380070362995L;
+ long dur = 60 * 60 * 1000;
+ long exp = iat + dur;
+
+ VerifyingPublicKey mockMyIdPublicKey = DSACryptoImplementation.createPublicKey(MOCKMYID_y, MOCKMYID_p, MOCKMYID_q, MOCKMYID_g);
+ SigningPrivateKey mockMyIdPrivateKey = DSACryptoImplementation.createPrivateKey(MOCKMYID_x, MOCKMYID_p, MOCKMYID_q, MOCKMYID_g);
+ VerifyingPublicKey publicKeyToSign = DSACryptoImplementation.createPublicKey(y, p, q, g);
+ SigningPrivateKey privateKeyToSignWith = DSACryptoImplementation.createPrivateKey(x, p, q, g);
+
+ String certificate = JSONWebTokenUtils.createCertificate(publicKeyToSign, "test@mockmyid.com", "mockmyid.com", iat, exp, mockMyIdPrivateKey);
+ String assertion = JSONWebTokenUtils.createAssertion(privateKeyToSignWith, certificate, TEST_AUDIENCE, TEST_ASSERTION_ISSUER, iat, exp);
+ String payload = JSONWebTokenUtils.decode(certificate, mockMyIdPublicKey);
+
+ String EXPECTED_PAYLOAD = "{\"exp\":1380073962995,\"iat\":1380070362995,\"iss\":\"mockmyid.com\",\"principal\":{\"email\":\"test@mockmyid.com\"},\"public-key\":{\"g\":\"f7e1a085d69b3ddecbbcab5c36b857b97994afbbfa3aea82f9574c0b3d0782675159578ebad4594fe67107108180b449167123e84c281613b7cf09328cc8a6e13c167a8b547c8d28e0a3ae1e2bb3a675916ea37f0bfa213562f1fb627a01243bcca4f1bea8519089a883dfe15ae59f06928b665e807b552564014c3bfecf492a\",\"q\":\"9760508f15230bccb292b982a2eb840bf0581cf5\",\"p\":\"fd7f53811d75122952df4a9c2eece4e7f611b7523cef4400c31e3f80b6512669455d402251fb593d8d58fabfc5f5ba30f6cb9b556cd7813b801d346ff26660b76b9950a5a49f9fe8047b1022c24fbba9d7feb7c61bf83b57e7c6a8a6150f04fb83f6d3c51ec3023554135a169132f675f3ae2b61d72aeff22203199dd14801c7\",\"y\":\"ea809be508bc94485553efac8ef2a8debdcdb3545ce433e8bd5889ec9d0880a13b2a8af35451161e58229d1e2be69e74a7251465a394913e8e64b0c33fde39a637b6047d7370178cf4404c0a7b4c2ed31d9cfe03ab79dbcc64667e6e7bc244eb1c127c28d725db94aff29b858bdb636f1307bdf48b3c91f387c2ab588086b6c8\",\"algorithm\":\"DS\"}}";
+ Assert.assertEquals(EXPECTED_PAYLOAD, payload);
+
+ // Really(!) brittle tests below. The DSA signature algorithm is not deterministic, so we can't test the actual signature.
+ String EXPECTED_CERTIFICATE_PREFIX = "eyJhbGciOiJEUzEyOCJ9.eyJleHAiOjEzODAwNzM5NjI5OTUsImlhdCI6MTM4MDA3MDM2Mjk5NSwiaXNzIjoibW9ja215aWQuY29tIiwicHJpbmNpcGFsIjp7ImVtYWlsIjoidGVzdEBtb2NrbXlpZC5jb20ifSwicHVibGljLWtleSI6eyJnIjoiZjdlMWEwODVkNjliM2RkZWNiYmNhYjVjMzZiODU3Yjk3OTk0YWZiYmZhM2FlYTgyZjk1NzRjMGIzZDA3ODI2NzUxNTk1NzhlYmFkNDU5NGZlNjcxMDcxMDgxODBiNDQ5MTY3MTIzZTg0YzI4MTYxM2I3Y2YwOTMyOGNjOGE2ZTEzYzE2N2E4YjU0N2M4ZDI4ZTBhM2FlMWUyYmIzYTY3NTkxNmVhMzdmMGJmYTIxMzU2MmYxZmI2MjdhMDEyNDNiY2NhNGYxYmVhODUxOTA4OWE4ODNkZmUxNWFlNTlmMDY5MjhiNjY1ZTgwN2I1NTI1NjQwMTRjM2JmZWNmNDkyYSIsInEiOiI5NzYwNTA4ZjE1MjMwYmNjYjI5MmI5ODJhMmViODQwYmYwNTgxY2Y1IiwicCI6ImZkN2Y1MzgxMWQ3NTEyMjk1MmRmNGE5YzJlZWNlNGU3ZjYxMWI3NTIzY2VmNDQwMGMzMWUzZjgwYjY1MTI2Njk0NTVkNDAyMjUxZmI1OTNkOGQ1OGZhYmZjNWY1YmEzMGY2Y2I5YjU1NmNkNzgxM2I4MDFkMzQ2ZmYyNjY2MGI3NmI5OTUwYTVhNDlmOWZlODA0N2IxMDIyYzI0ZmJiYTlkN2ZlYjdjNjFiZjgzYjU3ZTdjNmE4YTYxNTBmMDRmYjgzZjZkM2M1MWVjMzAyMzU1NDEzNWExNjkxMzJmNjc1ZjNhZTJiNjFkNzJhZWZmMjIyMDMxOTlkZDE0ODAxYzciLCJ5IjoiZWE4MDliZTUwOGJjOTQ0ODU1NTNlZmFjOGVmMmE4ZGViZGNkYjM1NDVjZTQzM2U4YmQ1ODg5ZWM5ZDA4ODBhMTNiMmE4YWYzNTQ1MTE2MWU1ODIyOWQxZTJiZTY5ZTc0YTcyNTE0NjVhMzk0OTEzZThlNjRiMGMzM2ZkZTM5YTYzN2I2MDQ3ZDczNzAxNzhjZjQ0MDRjMGE3YjRjMmVkMzFkOWNmZTAzYWI3OWRiY2M2NDY2N2U2ZTdiYzI0NGViMWMxMjdjMjhkNzI1ZGI5NGFmZjI5Yjg1OGJkYjYzNmYxMzA3YmRmNDhiM2M5MWYzODdjMmFiNTg4MDg2YjZjOCIsImFsZ29yaXRobSI6IkRTIn19";
+ String[] expectedCertificateParts = EXPECTED_CERTIFICATE_PREFIX.split("\\.");
+ String[] certificateParts = certificate.split("\\.");
+ Assert.assertEquals(expectedCertificateParts[0], certificateParts[0]);
+ Assert.assertEquals(expectedCertificateParts[1], certificateParts[1]);
+
+ String EXPECTED_ASSERTION_FRAGMENT = "eyJhbGciOiJEUzEyOCJ9.eyJhdWQiOiJodHRwOlwvXC9sb2NhbGhvc3Q6ODA4MCIsImV4cCI6MTM4MDA3Mzk2Mjk5NSwiaWF0IjoxMzgwMDcwMzYyOTk1LCJpc3MiOiIxMjcuMC4wLjEifQ";
+ String[] expectedAssertionParts = EXPECTED_ASSERTION_FRAGMENT.split("\\.");
+ String[] assertionParts = assertion.split("~")[1].split("\\.");
+ Assert.assertEquals(expectedAssertionParts[0], assertionParts[0]);
+ Assert.assertEquals(expectedAssertionParts[1], assertionParts[1]);
+ }
+
+ @Test
+ public void testGetPayloadString() throws Exception {
+ String s;
+ s = JSONWebTokenUtils.getPayloadString("{}", "audience", "issuer", 1L, 2L);
+ Assert.assertEquals("{\"aud\":\"audience\",\"exp\":2,\"iat\":1,\"iss\":\"issuer\"}", s);
+
+ // Make sure we don't include null issuedAt.
+ s = JSONWebTokenUtils.getPayloadString("{}", "audience", "issuer", null, 3L);
+ Assert.assertEquals("{\"aud\":\"audience\",\"exp\":3,\"iss\":\"issuer\"}", s);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestRSACryptoImplementation.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestRSACryptoImplementation.java
new file mode 100644
index 000000000..6dfa88ebf
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestRSACryptoImplementation.java
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.browserid.test;
+
+import junit.framework.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.RSACryptoImplementation;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+import java.math.BigInteger;
+
+@RunWith(TestRunner.class)
+public class TestRSACryptoImplementation {
+ @Test
+ public void testToJSONObject() throws Exception {
+ BigInteger n = new BigInteger("7042170764319402120473546823641395184140303948430445023576085129538272863656735924617881022040465877164076593767104512065359975488480629290310209335113577");
+ BigInteger e = new BigInteger("65537");
+ BigInteger d = new BigInteger("2050102629239206449128199335463237235732683202345308155771672920433658970744825199440426256856862541525088288448769859770132714705204296375901885294992205");
+
+ BrowserIDKeyPair keyPair = new BrowserIDKeyPair(
+ RSACryptoImplementation.createPrivateKey(n, d),
+ RSACryptoImplementation.createPublicKey(n, e));
+
+ ExtendedJSONObject o = new ExtendedJSONObject("{\"publicKey\":{\"e\":\"65537\",\"n\":\"7042170764319402120473546823641395184140303948430445023576085129538272863656735924617881022040465877164076593767104512065359975488480629290310209335113577\",\"algorithm\":\"RS\"},\"privateKey\":{\"d\":\"2050102629239206449128199335463237235732683202345308155771672920433658970744825199440426256856862541525088288448769859770132714705204296375901885294992205\",\"n\":\"7042170764319402120473546823641395184140303948430445023576085129538272863656735924617881022040465877164076593767104512065359975488480629290310209335113577\",\"algorithm\":\"RS\"}}");
+ Assert.assertEquals(o.getObject("privateKey"), keyPair.toJSONObject().getObject("privateKey"));
+ Assert.assertEquals(o.getObject("publicKey"), keyPair.toJSONObject().getObject("publicKey"));
+ }
+
+ @Test
+ public void testFromJSONObject() throws Exception {
+ BigInteger n = new BigInteger("7042170764319402120473546823641395184140303948430445023576085129538272863656735924617881022040465877164076593767104512065359975488480629290310209335113577");
+ BigInteger e = new BigInteger("65537");
+ BigInteger d = new BigInteger("2050102629239206449128199335463237235732683202345308155771672920433658970744825199440426256856862541525088288448769859770132714705204296375901885294992205");
+
+ BrowserIDKeyPair keyPair = new BrowserIDKeyPair(
+ RSACryptoImplementation.createPrivateKey(n, d),
+ RSACryptoImplementation.createPublicKey(n, e));
+
+ ExtendedJSONObject o = new ExtendedJSONObject("{\"publicKey\":{\"e\":\"65537\",\"n\":\"7042170764319402120473546823641395184140303948430445023576085129538272863656735924617881022040465877164076593767104512065359975488480629290310209335113577\",\"algorithm\":\"RS\"},\"privateKey\":{\"d\":\"2050102629239206449128199335463237235732683202345308155771672920433658970744825199440426256856862541525088288448769859770132714705204296375901885294992205\",\"n\":\"7042170764319402120473546823641395184140303948430445023576085129538272863656735924617881022040465877164076593767104512065359975488480629290310209335113577\",\"algorithm\":\"RS\"}}");
+
+ Assert.assertEquals(keyPair.getPublic().toJSONObject(), RSACryptoImplementation.createPublicKey(o.getObject("publicKey")).toJSONObject());
+ Assert.assertEquals(keyPair.getPrivate().toJSONObject(), RSACryptoImplementation.createPrivateKey(o.getObject("privateKey")).toJSONObject());
+ }
+
+ @Test
+ public void testRoundTrip() throws Exception {
+ BrowserIDKeyPair keyPair = RSACryptoImplementation.generateKeyPair(512);
+ ExtendedJSONObject o = keyPair.toJSONObject();
+ BrowserIDKeyPair keyPair2 = RSACryptoImplementation.fromJSONObject(o);
+ Assert.assertEquals(o, keyPair2.toJSONObject());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/cleanup/TestFileCleanupController.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/cleanup/TestFileCleanupController.java
new file mode 100644
index 000000000..6858b65a7
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/cleanup/TestFileCleanupController.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.cleanup;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.ArrayList;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.atMost;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests functionality of the {@link FileCleanupController}.
+ */
+@RunWith(TestRunner.class)
+public class TestFileCleanupController {
+
+ @Test
+ public void testStartIfReadyEmptySharedPrefsRunsCleanup() {
+ final Context context = mock(Context.class);
+ FileCleanupController.startIfReady(context, getSharedPreferences(), "");
+ verify(context).startService(any(Intent.class));
+ }
+
+ @Test
+ public void testStartIfReadyLastRunNowDoesNotRun() {
+ final SharedPreferences sharedPrefs = getSharedPreferences();
+ sharedPrefs.edit()
+ .putLong(FileCleanupController.PREF_LAST_CLEANUP_MILLIS, System.currentTimeMillis())
+ .commit(); // synchronous to finish before test runs.
+
+ final Context context = mock(Context.class);
+ FileCleanupController.startIfReady(context, sharedPrefs, "");
+
+ verify(context, never()).startService((any(Intent.class)));
+ }
+
+ /**
+ * Depends on {@link #testStartIfReadyEmptySharedPrefsRunsCleanup()} success –
+ * i.e. we expect the cleanup to run with empty prefs.
+ */
+ @Test
+ public void testStartIfReadyDoesNotRunTwiceInSuccession() {
+ final Context context = mock(Context.class);
+ final SharedPreferences sharedPrefs = getSharedPreferences();
+
+ FileCleanupController.startIfReady(context, sharedPrefs, "");
+ verify(context).startService(any(Intent.class));
+
+ // Note: the Controller relies on SharedPrefs.apply, but
+ // robolectric made this a synchronous call. Yay!
+ FileCleanupController.startIfReady(context, sharedPrefs, "");
+ verify(context, atMost(1)).startService(any(Intent.class));
+ }
+
+ @Test
+ public void testGetFilesToCleanupContainsProfilePath() {
+ final String profilePath = "/a/profile/path";
+ final ArrayList<String> fileList = FileCleanupController.getFilesToCleanup(profilePath);
+ assertNotNull("Returned file list is non-null", fileList);
+
+ boolean atLeastOneStartsWithProfilePath = false;
+ final String pathToCheck = profilePath + "/"; // Ensure the calling code adds a slash to divide the path.
+ for (final String path : fileList) {
+ if (path.startsWith(pathToCheck)) {
+ // It'd be great if we could assert these individually so
+ // we could display the Strings in console output.
+ atLeastOneStartsWithProfilePath = true;
+ }
+ }
+ assertTrue("At least one returned String starts with a profile path", atLeastOneStartsWithProfilePath);
+ }
+
+ private SharedPreferences getSharedPreferences() {
+ return RuntimeEnvironment.application.getSharedPreferences("TestFileCleanupController", 0);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/cleanup/TestFileCleanupService.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/cleanup/TestFileCleanupService.java
new file mode 100644
index 000000000..0326adb6a
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/cleanup/TestFileCleanupService.java
@@ -0,0 +1,106 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.cleanup;
+
+import android.content.Intent;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+/**
+ * Tests the methods of {@link FileCleanupService}.
+ */
+@RunWith(TestRunner.class)
+public class TestFileCleanupService {
+ @Rule
+ public final TemporaryFolder tempFolder = new TemporaryFolder();
+
+ private void assertAllFilesExist(final List<File> fileList) {
+ for (final File file : fileList) {
+ assertTrue("File exists", file.exists());
+ }
+ }
+
+ private void assertAllFilesDoNotExist(final List<File> fileList) {
+ for (final File file : fileList) {
+ assertFalse("File does not exist", file.exists());
+ }
+ }
+
+ private void onHandleIntent(final ArrayList<String> filePaths) {
+ final FileCleanupService service = new FileCleanupService();
+ final Intent intent = new Intent(FileCleanupService.ACTION_DELETE_FILES);
+ intent.putExtra(FileCleanupService.EXTRA_FILE_PATHS_TO_DELETE, filePaths);
+ service.onHandleIntent(intent);
+ }
+
+ @Test
+ public void testOnHandleIntentDeleteSpecifiedFiles() throws Exception {
+ final int fileListCount = 3;
+ final ArrayList<File> filesToDelete = generateFileList(fileListCount);
+
+ final ArrayList<String> pathsToDelete = new ArrayList<>(fileListCount);
+ for (final File file : filesToDelete) {
+ pathsToDelete.add(file.getAbsolutePath());
+ }
+
+ assertAllFilesExist(filesToDelete);
+ onHandleIntent(pathsToDelete);
+ assertAllFilesDoNotExist(filesToDelete);
+ }
+
+ @Test
+ public void testOnHandleIntentDoesNotDeleteUnrelatedFiles() throws Exception {
+ final ArrayList<File> filesShouldNotBeDeleted = generateFileList(3);
+ assertAllFilesExist(filesShouldNotBeDeleted);
+ onHandleIntent(new ArrayList<String>());
+ assertAllFilesExist(filesShouldNotBeDeleted);
+ }
+
+ @Test
+ public void testOnHandleIntentDeletesEmptyDirectory() throws Exception {
+ final File dir = tempFolder.newFolder();
+ final ArrayList<String> filesToDelete = new ArrayList<>(1);
+ filesToDelete.add(dir.getAbsolutePath());
+
+ assertTrue("Empty directory exists", dir.exists());
+ onHandleIntent(filesToDelete);
+ assertFalse("Empty directory deleted by service", dir.exists());
+ }
+
+ @Test
+ public void testOnHandleIntentDoesNotDeleteNonEmptyDirectory() throws Exception {
+ final File dir = tempFolder.newFolder();
+ final ArrayList<String> filesCannotDelete = new ArrayList<>(1);
+ filesCannotDelete.add(dir.getAbsolutePath());
+ assertTrue("Directory exists", dir.exists());
+
+ final File fileInDir = new File(dir, "file_in_dir");
+ assertTrue("File in dir created", fileInDir.createNewFile());
+
+ onHandleIntent(filesCannotDelete);
+ assertTrue("Non-empty directory not deleted", dir.exists());
+ assertTrue("File in directory not deleted", fileInDir.exists());
+ }
+
+ private ArrayList<File> generateFileList(final int size) throws IOException {
+ final ArrayList<File> fileList = new ArrayList<>(size);
+ for (int i = 0; i < size; ++i) {
+ fileList.add(tempFolder.newFile());
+ }
+ return fileList;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserContractTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserContractTest.java
new file mode 100644
index 000000000..e36153d0e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserContractTest.java
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.db;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class BrowserContractTest {
+ @Test
+ /**
+ * Test that bookmark and sorting order clauses are set correctly
+ */
+ public void testGetCombinedFrecencySortOrder() throws Exception {
+ String sqlNoBookmarksDesc = BrowserContract.getCombinedFrecencySortOrder(false, false);
+ String sqlNoBookmarksAsc = BrowserContract.getCombinedFrecencySortOrder(false, true);
+ String sqlBookmarksDesc = BrowserContract.getCombinedFrecencySortOrder(true, false);
+ String sqlBookmarksAsc = BrowserContract.getCombinedFrecencySortOrder(true, true);
+
+ assertTrue(sqlBookmarksAsc.endsWith(" ASC"));
+ assertTrue(sqlBookmarksDesc.endsWith(" DESC"));
+ assertTrue(sqlNoBookmarksAsc.endsWith(" ASC"));
+ assertTrue(sqlNoBookmarksDesc.endsWith(" DESC"));
+
+ assertTrue(sqlBookmarksAsc.startsWith("(CASE WHEN bookmark_id > -1 THEN 100 ELSE 0 END) + "));
+ assertTrue(sqlBookmarksDesc.startsWith("(CASE WHEN bookmark_id > -1 THEN 100 ELSE 0 END) + "));
+ }
+
+ @Test
+ /**
+ * Test that calculation string is correct for remote visits
+ * maxFrecency=1, scaleConst=110, correct sql params for visit count and last date
+ * and that time is converted to microseconds.
+ */
+ public void testGetRemoteFrecencySQL() throws Exception {
+ long now = 1;
+ String sql = BrowserContract.getRemoteFrecencySQL(now);
+ String ageExpr = "(" + now * 1000 + " - remoteDateLastVisited) / 86400000000";
+
+ assertEquals(
+ "remoteVisitCount * MAX(1, 100 * 110 / (" + ageExpr + " * " + ageExpr + " + 110))",
+ sql
+ );
+ }
+
+ @Test
+ /**
+ * Test that calculation string is correct for remote visits
+ * maxFrecency=2, scaleConst=225, correct sql params for visit count and last date
+ * and that time is converted to microseconds.
+ */
+ public void testGetLocalFrecencySQL() throws Exception {
+ long now = 1;
+ String sql = BrowserContract.getLocalFrecencySQL(now);
+ String ageExpr = "(" + now * 1000 + " - localDateLastVisited) / 86400000000";
+ String visitCountExpr = "(localVisitCount + 2) * (localVisitCount + 2)";
+
+ assertEquals(
+ visitCountExpr + " * MAX(2, 100 * 225 / (" + ageExpr + " * " + ageExpr + " + 225))",
+ sql
+ );
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHighlightsTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHighlightsTest.java
new file mode 100644
index 000000000..f8af41e32
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHighlightsTest.java
@@ -0,0 +1,438 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.repositories.android.BrowserContractHelpers;
+import org.mozilla.gecko.sync.setup.Constants;
+
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import static org.mozilla.gecko.db.BrowserContract.PARAM_PROFILE;
+
+/**
+ * Unit tests for the highlights query (Activity Stream).
+ */
+@RunWith(TestRunner.class)
+public class BrowserProviderHighlightsTest extends BrowserProviderHistoryVisitsTestBase {
+ private ContentProviderClient highlightsClient;
+ private ContentProviderClient activityStreamBlocklistClient;
+ private ContentProviderClient bookmarksClient;
+
+ private Uri highlightsTestUri;
+ private Uri activityStreamBlocklistTestUri;
+ private Uri bookmarksTestUri;
+
+ private Uri expireHistoryNormalUri;
+
+ @Before
+ public void setUp() throws Exception {
+ super.setUp();
+
+ final Uri highlightsClientUri = BrowserContract.Highlights.CONTENT_URI.buildUpon()
+ .appendQueryParameter(PARAM_PROFILE, Constants.DEFAULT_PROFILE)
+ .build();
+
+ final Uri activityStreamBlocklistClientUri = BrowserContract.ActivityStreamBlocklist.CONTENT_URI.buildUpon()
+ .appendQueryParameter(PARAM_PROFILE, Constants.DEFAULT_PROFILE)
+ .build();
+
+ highlightsClient = contentResolver.acquireContentProviderClient(highlightsClientUri);
+ activityStreamBlocklistClient = contentResolver.acquireContentProviderClient(activityStreamBlocklistClientUri);
+ bookmarksClient = contentResolver.acquireContentProviderClient(BrowserContractHelpers.BOOKMARKS_CONTENT_URI);
+
+ highlightsTestUri = testUri(BrowserContract.Highlights.CONTENT_URI);
+ activityStreamBlocklistTestUri = testUri(BrowserContract.ActivityStreamBlocklist.CONTENT_URI);
+ bookmarksTestUri = testUri(BrowserContract.Bookmarks.CONTENT_URI);
+
+ expireHistoryNormalUri = testUri(BrowserContract.History.CONTENT_OLD_URI).buildUpon()
+ .appendQueryParameter(
+ BrowserContract.PARAM_EXPIRE_PRIORITY,
+ BrowserContract.ExpirePriority.NORMAL.toString()
+ ).build();
+ }
+
+ @After
+ public void tearDown() {
+ highlightsClient.release();
+ activityStreamBlocklistClient.release();
+ bookmarksClient.release();
+
+ super.tearDown();
+ }
+
+ /**
+ * Scenario: Empty database, no history, no bookmarks.
+ *
+ * Assert that:
+ * - Empty cursor (not null) is returned.
+ */
+ @Test
+ public void testEmptyDatabase() throws Exception {
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+
+ Assert.assertEquals(0, cursor.getCount());
+
+ cursor.close();
+ }
+
+ /**
+ * Scenario: The database only contains very recent history (now, 5 minutes ago, 20 minutes).
+ *
+ * Assert that:
+ * - No highlight is returned from recent history.
+ */
+ @Test
+ public void testOnlyRecentHistory() throws Exception {
+ final long now = System.currentTimeMillis();
+ final long fiveMinutesAgo = now - 1000 * 60 * 5;
+ final long twentyMinutes = now - 1000 * 60 * 20;
+
+ insertHistoryItem(createUniqueUrl(), createGUID(), now, 1, createUniqueTitle());
+ insertHistoryItem(createUniqueUrl(), createGUID(), fiveMinutesAgo, 1, createUniqueTitle());
+ insertHistoryItem(createUniqueUrl(), createGUID(), twentyMinutes, 1, createUniqueTitle());
+
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+
+ Assert.assertNotNull(cursor);
+
+ Assert.assertEquals(0, cursor.getCount());
+
+ cursor.close();
+ }
+
+ /**
+ * Scenario: The database contains recent (but not too fresh) history (1 hour, 5 days).
+ *
+ * Assert that:
+ * - Highlights are returned from history.
+ */
+ @Test
+ public void testHighlightsArePickedFromHistory() throws Exception {
+ final String url1 = createUniqueUrl();
+ final String url2 = createUniqueUrl();
+ final String title1 = createUniqueTitle();
+ final String title2 = createUniqueTitle();
+
+ final long oneHourAgo = System.currentTimeMillis() - 1000 * 60 * 60;
+ final long fiveDaysAgo = System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 5;
+
+ insertHistoryItem(url1, createGUID(), oneHourAgo, 1, title1);
+ insertHistoryItem(url2, createGUID(), fiveDaysAgo, 1, title2);
+
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+
+ Assert.assertEquals(2, cursor.getCount());
+
+ assertCursorContainsEntry(cursor, url1, title1);
+ assertCursorContainsEntry(cursor, url2, title2);
+
+ cursor.close();
+ }
+
+ /**
+ * Scenario: The database contains history that is visited frequently and rarely.
+ *
+ * Assert that:
+ * - Highlights are picked from rarely visited websites.
+ * - Highlights are not picked from frequently visited websites.
+ */
+ @Test
+ public void testOftenVisitedPagesAreNotPicked() throws Exception {
+ final String url1 = createUniqueUrl();
+ final String title1 = createUniqueTitle();
+
+ final long oneHourAgo = System.currentTimeMillis() - 1000 * 60 * 60;
+ final long fiveDaysAgo = System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 5;
+
+ insertHistoryItem(url1, createGUID(), oneHourAgo, 2, title1);
+ insertHistoryItem(createUniqueUrl(), createGUID(), fiveDaysAgo, 25, createUniqueTitle());
+
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+
+ // Verify that only the first URL (with one visit) is picked and the second URL with 25 visits is ignored.
+
+ Assert.assertEquals(1, cursor.getCount());
+
+ cursor.moveToNext();
+ assertCursor(cursor, url1, title1);
+
+ cursor.close();
+ }
+
+ /**
+ * Scenario: The database contains history with and without titles.
+ *
+ * Assert that:
+ * - History without titles is not picked for highlights.
+ */
+ @Test
+ public void testHistoryWithoutTitlesIsNotPicked() throws Exception {
+ final String url1 = createUniqueUrl();
+ final String url2 = createUniqueUrl();
+ final String title1 = "";
+ final String title2 = createUniqueTitle();
+
+ final long oneHourAgo = System.currentTimeMillis() - 1000 * 60 * 60;
+ final long fiveDaysAgo = System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 5;
+
+ insertHistoryItem(url1, createGUID(), oneHourAgo, 1, title1);
+ insertHistoryItem(url2, createGUID(), fiveDaysAgo, 1, title2);
+
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+
+ // Only one bookmark will be picked for highlights
+ Assert.assertEquals(1, cursor.getCount());
+
+ cursor.moveToNext();
+ assertCursor(cursor, url2, title2);
+
+ cursor.close();
+ }
+
+ /**
+ * Scenario: Database contains two bookmarks (unvisited).
+ *
+ * Assert that:
+ * - One bookmark is picked for highlights.
+ */
+ @Test
+ public void testPickingBookmarkForHighlights() throws Exception {
+ final long oneHourAgo = System.currentTimeMillis() - 1000 * 60 * 60;
+ final long fiveDaysAgo = System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 5;
+
+ final String url1 = createUniqueUrl();
+ final String url2 = createUniqueUrl();
+ final String title1 = createUniqueTitle();
+ final String title2 = createUniqueTitle();
+
+ insertBookmarkItem(url1, title1, oneHourAgo);
+ insertBookmarkItem(url2, title2, fiveDaysAgo);
+
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+
+ Assert.assertEquals(1, cursor.getCount());
+
+ cursor.moveToNext();
+ assertCursor(cursor, url1, title1);
+
+ cursor.close();
+ }
+
+ /**
+ * Scenario: Database contains an often visited bookmark.
+ *
+ * Assert that:
+ * - Bookmark is not selected for highlights.
+ */
+ @Test
+ public void testOftenVisitedBookmarksWillNotBePicked() throws Exception {
+ final String url = createUniqueUrl();
+ final long oneHourAgo = System.currentTimeMillis() - 1000 * 60 * 60;
+
+ insertBookmarkItem(url, createUniqueTitle(), oneHourAgo);
+ insertHistoryItem(url, createGUID(), oneHourAgo, 25, createUniqueTitle());
+
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+
+ Assert.assertEquals(0, cursor.getCount());
+
+ cursor.close();
+ }
+
+ /**
+ * Scenario: Database contains URL as bookmark and in history (not visited often).
+ *
+ * Assert that:
+ * - URL is not picked twice (as bookmark and from history)
+ */
+ @Test
+ public void testSameUrlIsNotPickedFromHistoryAndBookmarks() throws Exception {
+ final String url = createUniqueUrl();
+
+ final long oneHourAgo = System.currentTimeMillis() - 1000 * 60 * 60;
+
+ // Insert bookmark that is picked for highlights
+ insertBookmarkItem(url, createUniqueTitle(), oneHourAgo);
+ // Insert history for same URL that would be picked for highlights too
+ insertHistoryItem(url, createGUID(), oneHourAgo, 2, createUniqueTitle());
+
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+
+ Assert.assertEquals(1, cursor.getCount());
+
+ cursor.close();
+ }
+
+ /**
+ * Scenario: Database contains only old bookmarks.
+ *
+ * Assert that:
+ * - Old bookmarks are not selected as highlight.
+ */
+ @Test
+ public void testVeryOldBookmarksAreNotSelected() throws Exception {
+ final long oneWeekAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7);
+ final long oneMonthAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31);
+ final long oneYearAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(365);
+
+ insertBookmarkItem(createUniqueUrl(), createUniqueTitle(), oneWeekAgo);
+ insertBookmarkItem(createUniqueUrl(), createUniqueTitle(), oneMonthAgo);
+ insertBookmarkItem(createUniqueUrl(), createUniqueTitle(), oneYearAgo);
+
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+
+ Assert.assertEquals(0, cursor.getCount());
+
+ cursor.close();
+ }
+
+ @Test
+ public void testBlocklistItemsAreNotSelected() throws Exception {
+ final long oneDayAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1);
+
+ final String blockURL = createUniqueUrl();
+
+ insertBookmarkItem(blockURL, createUniqueTitle(), oneDayAgo);
+
+ Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+ Assert.assertEquals(1, cursor.getCount());
+ cursor.close();
+
+ insertBlocklistItem(blockURL);
+
+ cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+ Assert.assertEquals(0, cursor.getCount());
+ cursor.close();
+ }
+
+ @Test
+ public void testBlocklistItemsExpire() throws Exception {
+ final long oneDayAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1);
+
+ final String blockURL = createUniqueUrl();
+ final String blockTitle = createUniqueTitle();
+
+ insertBookmarkItem(blockURL, blockTitle, oneDayAgo);
+ insertBlocklistItem(blockURL);
+
+ {
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+ Assert.assertEquals(0, cursor.getCount());
+ cursor.close();
+ }
+
+ // Add (2000 / 10) items in the loop -> 201 items total
+ int itemsNeeded = BrowserProvider.DEFAULT_EXPIRY_RETAIN_COUNT / BrowserProvider.ACTIVITYSTREAM_BLOCKLIST_EXPIRY_FACTOR;
+ for (int i = 0; i < itemsNeeded; i++) {
+ insertBlocklistItem(createUniqueUrl());
+ }
+
+ // We still have zero highlights: the item is still blocked
+ {
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+ Assert.assertEquals(0, cursor.getCount());
+ cursor.close();
+ }
+
+ // expire the original blocked URL - only most recent 200 items are retained
+ historyClient.delete(expireHistoryNormalUri, null, null);
+
+ // And the original URL is now in highlights again (note: this shouldn't happen in real life,
+ // since the URL will no longer be eligible for highlights by the time we expire it)
+ {
+ final Cursor cursor = highlightsClient.query(highlightsTestUri, null, null, null, null);
+ Assert.assertNotNull(cursor);
+ Assert.assertEquals(1, cursor.getCount());
+
+ cursor.moveToFirst();
+ assertCursor(cursor, blockURL, blockTitle);
+ cursor.close();
+ }
+ }
+
+ private void insertBookmarkItem(String url, String title, long createdAt) throws RemoteException {
+ ContentValues values = new ContentValues();
+
+ values.put(BrowserContract.Bookmarks.URL, url);
+ values.put(BrowserContract.Bookmarks.TITLE, title);
+ values.put(BrowserContract.Bookmarks.PARENT, 0);
+ values.put(BrowserContract.Bookmarks.TYPE, BrowserContract.Bookmarks.TYPE_BOOKMARK);
+ values.put(BrowserContract.Bookmarks.DATE_CREATED, createdAt);
+
+ bookmarksClient.insert(bookmarksTestUri, values);
+ }
+
+ private void insertBlocklistItem(String url) throws RemoteException {
+ final ContentValues values = new ContentValues();
+ values.put(BrowserContract.ActivityStreamBlocklist.URL, url);
+
+ activityStreamBlocklistClient.insert(activityStreamBlocklistTestUri, values);
+ }
+
+ private void assertCursor(Cursor cursor, String url, String title) {
+ final String actualTitle = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE));
+ Assert.assertEquals(title, actualTitle);
+
+ final String actualUrl = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+ Assert.assertEquals(url, actualUrl);
+ }
+
+ private void assertCursorContainsEntry(Cursor cursor, String url, String title) {
+ cursor.moveToFirst();
+
+ do {
+ final String actualTitle = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE));
+ final String actualUrl = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+
+ if (actualTitle.equals(title) && actualUrl.equals(url)) {
+ return;
+ }
+ } while (cursor.moveToNext());
+
+ Assert.fail("Could not find entry title=" + title + ", url=" + url);
+ }
+
+ private String createUniqueUrl() {
+ return new Uri.Builder()
+ .scheme("https")
+ .authority(UUID.randomUUID().toString() + ".example.org")
+ .appendPath(UUID.randomUUID().toString())
+ .appendPath(UUID.randomUUID().toString())
+ .build()
+ .toString();
+ }
+
+ private String createUniqueTitle() {
+ return "Title " + UUID.randomUUID().toString();
+ }
+
+ private String createGUID() {
+ return UUID.randomUUID().toString();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryTest.java
new file mode 100644
index 000000000..850841432
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryTest.java
@@ -0,0 +1,341 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.robolectric.shadows.ShadowContentResolver;
+
+import static org.junit.Assert.*;
+
+/**
+ * Testing functionality exposed by BrowserProvider ContentProvider (history, bookmarks, etc).
+ * This is WIP junit4 port of robocop tests at org.mozilla.gecko.tests.testBrowserProvider.
+ * See Bug 1269492
+ */
+@RunWith(TestRunner.class)
+public class BrowserProviderHistoryTest extends BrowserProviderHistoryVisitsTestBase {
+ private ContentProviderClient thumbnailClient;
+ private Uri thumbnailTestUri;
+ private Uri expireHistoryNormalUri;
+ private Uri expireHistoryAggressiveUri;
+
+ private static final long THREE_MONTHS = 1000L * 60L * 60L * 24L * 30L * 3L;
+
+ @Before
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ final ShadowContentResolver cr = new ShadowContentResolver();
+ thumbnailClient = cr.acquireContentProviderClient(BrowserContract.Thumbnails.CONTENT_URI);
+ thumbnailTestUri = testUri(BrowserContract.Thumbnails.CONTENT_URI);
+ expireHistoryNormalUri = testUri(BrowserContract.History.CONTENT_OLD_URI).buildUpon()
+ .appendQueryParameter(
+ BrowserContract.PARAM_EXPIRE_PRIORITY,
+ BrowserContract.ExpirePriority.NORMAL.toString()
+ ).build();
+ expireHistoryAggressiveUri = testUri(BrowserContract.History.CONTENT_OLD_URI).buildUpon()
+ .appendQueryParameter(
+ BrowserContract.PARAM_EXPIRE_PRIORITY,
+ BrowserContract.ExpirePriority.AGGRESSIVE.toString()
+ ).build();
+ }
+
+ @After
+ @Override
+ public void tearDown() {
+ thumbnailClient.release();
+ super.tearDown();
+ }
+
+ /**
+ * Test aggressive expiration on new (recent) history items
+ */
+ @Test
+ public void testHistoryExpirationAggressiveNew() throws Exception {
+ final int historyItemsCount = 3000;
+ insertHistory(historyItemsCount, System.currentTimeMillis());
+
+ historyClient.delete(expireHistoryAggressiveUri, null, null);
+
+ /**
+ * Aggressive expiration should leave 500 history items
+ * See {@link BrowserProvider.AGGRESSIVE_EXPIRY_RETAIN_COUNT}
+ */
+ assertRowCount(historyClient, historyTestUri, 500);
+
+ /**
+ * Aggressive expiration should leave 15 thumbnails
+ * See {@link BrowserProvider.DEFAULT_EXPIRY_THUMBNAIL_COUNT}
+ */
+ assertRowCount(thumbnailClient, thumbnailTestUri, 15);
+ }
+
+ /**
+ * Test normal expiration on new (recent) history items
+ */
+ @Test
+ public void testHistoryExpirationNormalNew() throws Exception {
+ final int historyItemsCount = 3000;
+ insertHistory(historyItemsCount, System.currentTimeMillis());
+
+ historyClient.delete(expireHistoryNormalUri, null, null);
+
+ // Normal expiration shouldn't expire new items
+ assertRowCount(historyClient, historyTestUri, 3000);
+
+ /**
+ * Normal expiration should leave 15 thumbnails
+ * See {@link BrowserProvider.DEFAULT_EXPIRY_THUMBNAIL_COUNT}
+ */
+ assertRowCount(thumbnailClient, thumbnailTestUri, 15);
+ }
+
+ /**
+ * Test aggressive expiration on old history items
+ */
+ @Test
+ public void testHistoryExpirationAggressiveOld() throws Exception {
+ final int historyItemsCount = 3000;
+ insertHistory(historyItemsCount, System.currentTimeMillis() - THREE_MONTHS);
+
+ historyClient.delete(expireHistoryAggressiveUri, null, null);
+
+ /**
+ * Aggressive expiration should leave 500 history items
+ * See {@link BrowserProvider.AGGRESSIVE_EXPIRY_RETAIN_COUNT}
+ */
+ assertRowCount(historyClient, historyTestUri, 500);
+
+ /**
+ * Aggressive expiration should leave 15 thumbnails
+ * See {@link BrowserProvider.DEFAULT_EXPIRY_THUMBNAIL_COUNT}
+ */
+ assertRowCount(thumbnailClient, thumbnailTestUri, 15);
+ }
+
+ /**
+ * Test normal expiration on old history items
+ */
+ @Test
+ public void testHistoryExpirationNormalOld() throws Exception {
+ final int historyItemsCount = 3000;
+ insertHistory(historyItemsCount, System.currentTimeMillis() - THREE_MONTHS);
+
+ historyClient.delete(expireHistoryNormalUri, null, null);
+
+ /**
+ * Normal expiration of old items should retain at most 2000 items
+ * See {@link BrowserProvider.DEFAULT_EXPIRY_RETAIN_COUNT}
+ */
+ assertRowCount(historyClient, historyTestUri, 2000);
+
+ /**
+ * Normal expiration should leave 15 thumbnails
+ * See {@link BrowserProvider.DEFAULT_EXPIRY_THUMBNAIL_COUNT}
+ */
+ assertRowCount(thumbnailClient, thumbnailTestUri, 15);
+ }
+
+ /**
+ * Test that we update aggregates at the appropriate times. Local visit aggregates are only updated
+ * when updating history record with PARAM_INCREMENT_VISITS=true. Remote aggregate values are updated
+ * only if set directly. Aggregate values are not set when inserting a new history record via insertHistory.
+ * Local aggregate values are set when inserting a new history record via update.
+ * @throws Exception
+ */
+ @Test
+ public void testHistoryVisitAggregates() throws Exception {
+ final long baseDate = System.currentTimeMillis();
+ final String url = "https://www.mozilla.org";
+ final Uri historyIncrementVisitsUri = historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true")
+ .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build();
+
+ // Test default values
+ insertHistoryItem(url, null, baseDate, null);
+ assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {url},
+ 0, 0, 0, 0, 0);
+
+ // Test setting visit count on new history item creation
+ final String url2 = "https://www.eff.org";
+ insertHistoryItem(url2, null, baseDate, 17);
+ assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {url2},
+ 17, 0, 0, 0, 0);
+
+ // Test setting visit count on new history item creation via .update
+ final String url3 = "https://www.torproject.org";
+ final ContentValues cv = new ContentValues();
+ cv.put(BrowserContract.History.URL, url3);
+ cv.put(BrowserContract.History.VISITS, 13);
+ cv.put(BrowserContract.History.DATE_LAST_VISITED, baseDate);
+ historyClient.update(historyIncrementVisitsUri, cv, BrowserContract.History.URL + " = ?", new String[] {url3});
+ assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {url3},
+ 13, 13, baseDate, 0, 0);
+
+ // Test that updating meta doesn't touch aggregates
+ cv.clear();
+ cv.put(BrowserContract.History.TITLE, "New title");
+ historyClient.update(historyTestUri, cv, BrowserContract.History.URL + " = ?", new String[] {url});
+ assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {url},
+ 0, 0, 0, 0, 0);
+
+ // Test that incrementing visits without specifying visit count updates local aggregate values
+ final long lastVisited = System.currentTimeMillis();
+ cv.clear();
+ cv.put(BrowserContract.History.DATE_LAST_VISITED, lastVisited);
+ historyClient.update(historyIncrementVisitsUri,
+ cv, BrowserContract.History.URL + " = ?", new String[] {url});
+ assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {url},
+ 1, 1, lastVisited, 0, 0);
+
+ // Test that incrementing visits by a specified visit count updates local aggregate values
+ // We don't support bumping visit count by more than 1. This doesn't make sense when we keep
+ // detailed information about our individual visits.
+ final long lastVisited2 = System.currentTimeMillis();
+ cv.clear();
+ cv.put(BrowserContract.History.DATE_LAST_VISITED, lastVisited2);
+ cv.put(BrowserContract.History.VISITS, 10);
+ historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+ cv, BrowserContract.History.URL + " = ?", new String[] {url});
+ assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {url},
+ 2, 2, lastVisited2, 0, 0);
+
+ // Test that we can directly update aggregate values
+ // NB: visits is unchanged (2)
+ final long lastVisited3 = System.currentTimeMillis();
+ cv.clear();
+ cv.put(BrowserContract.History.LOCAL_DATE_LAST_VISITED, lastVisited3);
+ cv.put(BrowserContract.History.LOCAL_VISITS, 19);
+ cv.put(BrowserContract.History.REMOTE_DATE_LAST_VISITED, lastVisited3 - 100);
+ cv.put(BrowserContract.History.REMOTE_VISITS, 3);
+ historyClient.update(historyTestUri, cv, BrowserContract.History.URL + " = ?", new String[] {url});
+ assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {url},
+ 2, 19, lastVisited3, 3, lastVisited3 - 100);
+
+ // Test that we can set remote aggregate count to a specific value
+ cv.clear();
+ cv.put(BrowserContract.History.REMOTE_VISITS, 5);
+ historyClient.update(historyTestUri, cv, BrowserContract.History.URL + " = ?", new String[] {url});
+ assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {url},
+ 2, 19, lastVisited3, 5, lastVisited3 - 100);
+
+ // Test that we can increment remote aggregate value by setting a query param in the URI
+ final Uri historyIncrementRemoteAggregateUri = historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_REMOTE_AGGREGATES, "true")
+ .build();
+ cv.clear();
+ cv.put(BrowserContract.History.REMOTE_DATE_LAST_VISITED, lastVisited3);
+ cv.put(BrowserContract.History.REMOTE_VISITS, 3);
+ historyClient.update(historyIncrementRemoteAggregateUri, cv, BrowserContract.History.URL + " = ?", new String[] {url});
+ // NB: remoteVisits=8. Previous value was 5, and we're incrementing by 3.
+ assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {url},
+ 2, 19, lastVisited3, 8, lastVisited3);
+
+ // Test that we throw when trying to increment REMOTE_VISITS without passing in "increment by" value
+ cv.clear();
+ try {
+ historyClient.update(historyIncrementRemoteAggregateUri, cv, BrowserContract.History.URL + " = ?", new String[]{url});
+ assertTrue("Expected to throw IllegalArgumentException", false);
+ } catch (IllegalArgumentException e) {
+ assertTrue(true);
+
+ // NB: same values as above, to ensure throwing update didn't actually change anything.
+ assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {url},
+ 2, 19, lastVisited3, 8, lastVisited3);
+ }
+ }
+
+ private void assertHistoryAggregates(String selection, String[] selectionArg, int visits, int localVisits, long localLastVisited, int remoteVisits, long remoteLastVisited) throws Exception {
+ final Cursor c = historyClient.query(historyTestUri, new String[] {
+ BrowserContract.History.VISITS,
+ BrowserContract.History.LOCAL_VISITS,
+ BrowserContract.History.REMOTE_VISITS,
+ BrowserContract.History.LOCAL_DATE_LAST_VISITED,
+ BrowserContract.History.REMOTE_DATE_LAST_VISITED
+ }, selection, selectionArg, null);
+
+ assertNotNull(c);
+ try {
+ assertTrue(c.moveToFirst());
+
+ final int visitsCol = c.getColumnIndexOrThrow(BrowserContract.History.VISITS);
+ final int localVisitsCol = c.getColumnIndexOrThrow(BrowserContract.History.LOCAL_VISITS);
+ final int remoteVisitsCol = c.getColumnIndexOrThrow(BrowserContract.History.REMOTE_VISITS);
+ final int localDateLastVisitedCol = c.getColumnIndexOrThrow(BrowserContract.History.LOCAL_DATE_LAST_VISITED);
+ final int remoteDateLastVisitedCol = c.getColumnIndexOrThrow(BrowserContract.History.REMOTE_DATE_LAST_VISITED);
+
+ assertEquals(visits, c.getInt(visitsCol));
+
+ assertEquals(localVisits, c.getInt(localVisitsCol));
+ assertEquals(localLastVisited, c.getLong(localDateLastVisitedCol));
+
+ assertEquals(remoteVisits, c.getInt(remoteVisitsCol));
+ assertEquals(remoteLastVisited, c.getLong(remoteDateLastVisitedCol));
+ } finally {
+ c.close();
+ }
+ }
+
+ /**
+ * Insert <code>count</code> history records with thumbnails, and for a third of records insert a visit.
+ * Inserting visits only for some of the history records is in order to ensure we're correctly JOIN-ing
+ * History and Visits tables in the Combined view.
+ * Will ensure that date_created and date_modified for new records are the same as last visited date.
+ *
+ * @param count number of history records to insert
+ * @param baseTime timestamp which will be used as a basis for last visited date
+ * @throws RemoteException
+ */
+ private void insertHistory(int count, long baseTime) throws RemoteException {
+ Uri incrementUri = historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build();
+
+ for (int i = 0; i < count; i++) {
+ final String url = "https://www.mozilla" + i + ".org";
+ insertHistoryItem(url, "testGUID" + i, baseTime - i, null);
+ if (i % 3 == 0) {
+ assertEquals(1, historyClient.update(incrementUri, new ContentValues(), BrowserContract.History.URL + " = ?", new String[]{url}));
+ }
+
+ // inserting a new entry sets the date created and modified automatically, so let's reset them
+ ContentValues cv = new ContentValues();
+ cv.put(BrowserContract.History.DATE_CREATED, baseTime - i);
+ cv.put(BrowserContract.History.DATE_MODIFIED, baseTime - i);
+ assertEquals(1, historyClient.update(historyTestUri, cv, BrowserContract.History.URL + " = ?",
+ new String[] { "https://www.mozilla" + i + ".org" }));
+ }
+
+ // insert thumbnails for history items
+ ContentValues[] thumbs = new ContentValues[count];
+ for (int i = 0; i < count; i++) {
+ thumbs[i] = new ContentValues();
+ thumbs[i].put(BrowserContract.Thumbnails.DATA, i);
+ thumbs[i].put(BrowserContract.Thumbnails.URL, "https://www.mozilla" + i + ".org");
+ }
+ assertEquals(count, thumbnailClient.bulkInsert(thumbnailTestUri, thumbs));
+ }
+
+ private void assertRowCount(final ContentProviderClient client, final Uri uri, final int count) throws RemoteException {
+ final Cursor c = client.query(uri, null, null, null, null);
+ assertNotNull(c);
+ try {
+ assertEquals(count, c.getCount());
+ } finally {
+ c.close();
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTest.java
new file mode 100644
index 000000000..71c21166d
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTest.java
@@ -0,0 +1,338 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import org.mozilla.gecko.db.BrowserContract.History;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+/**
+ * Testing insertion/deletion of visits as by-product of updating history records through BrowserProvider
+ */
+public class BrowserProviderHistoryVisitsTest extends BrowserProviderHistoryVisitsTestBase {
+ @Test
+ /**
+ * Testing updating history records without affecting visits
+ */
+ public void testUpdateNoVisit() throws Exception {
+ insertHistoryItem("https://www.mozilla.org", "testGUID");
+
+ Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(0, cursor.getCount());
+ cursor.close();
+
+ ContentValues historyUpdate = new ContentValues();
+ historyUpdate.put(History.TITLE, "Mozilla!");
+ assertEquals(1,
+ historyClient.update(
+ historyTestUri, historyUpdate, History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+ )
+ );
+
+ cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(0, cursor.getCount());
+ cursor.close();
+
+ ContentValues historyToInsert = new ContentValues();
+ historyToInsert.put(History.URL, "https://www.eff.org");
+ assertEquals(1,
+ historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(),
+ historyToInsert, null, null
+ )
+ );
+
+ cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(0, cursor.getCount());
+ cursor.close();
+ }
+
+ @Test
+ /**
+ * Testing INCREMENT_VISITS flag for multiple history records at once
+ */
+ public void testUpdateMultipleHistoryIncrementVisit() throws Exception {
+ insertHistoryItem("https://www.mozilla.org", "testGUID");
+ insertHistoryItem("https://www.mozilla.org", "testGUID2");
+
+ // test that visits get inserted when updating existing history records
+ assertEquals(2, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+ new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+ ));
+
+ Cursor cursor = visitsClient.query(
+ visitsTestUri, new String[] {BrowserContract.Visits.HISTORY_GUID}, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(2, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+
+ String guid1 = cursor.getString(cursor.getColumnIndex(BrowserContract.Visits.HISTORY_GUID));
+ cursor.moveToNext();
+ String guid2 = cursor.getString(cursor.getColumnIndex(BrowserContract.Visits.HISTORY_GUID));
+ cursor.close();
+
+ assertNotEquals(guid1, guid2);
+
+ assertTrue(guid1.equals("testGUID") || guid1.equals("testGUID2"));
+ }
+
+ @Test
+ /**
+ * Testing INCREMENT_VISITS flag and its interplay with INSERT_IF_NEEDED
+ */
+ public void testUpdateHistoryIncrementVisit() throws Exception {
+ insertHistoryItem("https://www.mozilla.org", "testGUID");
+
+ // test that visit gets inserted when updating an existing histor record
+ assertEquals(1, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+ new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+ ));
+
+ Cursor cursor = visitsClient.query(
+ visitsTestUri, new String[] {BrowserContract.Visits.HISTORY_GUID}, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(1, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+ assertEquals(
+ "testGUID",
+ cursor.getString(cursor.getColumnIndex(BrowserContract.Visits.HISTORY_GUID))
+ );
+ cursor.close();
+
+ // test that visit gets inserted when updatingOrInserting a new history record
+ ContentValues historyItem = new ContentValues();
+ historyItem.put(History.URL, "https://www.eff.org");
+
+ assertEquals(1, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true")
+ .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(),
+ historyItem, null, null
+ ));
+
+ cursor = historyClient.query(
+ historyTestUri,
+ new String[] {History.GUID}, History.URL + " = ?", new String[] {"https://www.eff.org"}, null
+ );
+ assertNotNull(cursor);
+ assertEquals(1, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+ String insertedGUID = cursor.getString(cursor.getColumnIndex(History.GUID));
+ cursor.close();
+
+ cursor = visitsClient.query(
+ visitsTestUri, new String[] {BrowserContract.Visits.HISTORY_GUID}, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(2, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+ assertEquals(insertedGUID,
+ cursor.getString(cursor.getColumnIndex(BrowserContract.Visits.HISTORY_GUID))
+ );
+ cursor.close();
+ }
+
+ @Test
+ /**
+ * Test that for locally generated visits, we store their timestamps in microseconds, and not in
+ * milliseconds like history does.
+ */
+ public void testTimestampConversionOnInsertion() throws Exception {
+ insertHistoryItem("https://www.mozilla.org", "testGUID");
+
+ Long lastVisited = System.currentTimeMillis();
+ ContentValues updatedVisitedTime = new ContentValues();
+ updatedVisitedTime.put(History.DATE_LAST_VISITED, lastVisited);
+
+ // test with last visited date passed in
+ assertEquals(1, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+ updatedVisitedTime, History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+ ));
+
+ Cursor cursor = visitsClient.query(visitsTestUri, new String[] {BrowserContract.Visits.DATE_VISITED}, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(1, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+
+ assertEquals(lastVisited * 1000, cursor.getLong(cursor.getColumnIndex(BrowserContract.Visits.DATE_VISITED)));
+ cursor.close();
+
+ // test without last visited date
+ assertEquals(1, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+ new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+ ));
+
+ cursor = visitsClient.query(visitsTestUri, new String[] {BrowserContract.Visits.DATE_VISITED}, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(2, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+
+ // CP should generate time off of current time upon insertion and convert to microseconds.
+ // This also tests correct ordering (DESC on date).
+ assertTrue(lastVisited * 1000 < cursor.getLong(cursor.getColumnIndex(BrowserContract.Visits.DATE_VISITED)));
+ cursor.close();
+ }
+
+ @Test
+ /**
+ * This should perform `DELETE FROM visits WHERE history_guid in IN (?, ?, ?, ..., ?)` sort of statement
+ * SQLite has a variable count limit (999 by default), so we're testing here that our deletion
+ * code does the right thing and chunks deletes to account for this limitation.
+ */
+ public void testDeletingLotsOfHistory() throws Exception {
+ Uri incrementUri = historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build();
+
+ // insert bunch of history records, and for each insert a visit
+ for (int i = 0; i < 2100; i++) {
+ final String url = "https://www.mozilla" + i + ".org";
+ insertHistoryItem(url, "testGUID" + i);
+ assertEquals(1, historyClient.update(incrementUri, new ContentValues(), History.URL + " = ?", new String[] {url}));
+ }
+
+ // sanity check
+ Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(2100, cursor.getCount());
+ cursor.close();
+
+ // delete all of the history items - this will trigger chunked deletion of visits as well
+ assertEquals(2100,
+ historyClient.delete(historyTestUri, null, null)
+ );
+
+ // check that all visits where deleted
+ cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(0, cursor.getCount());
+ cursor.close();
+ }
+
+ @Test
+ /**
+ * Test visit deletion as by-product of history deletion - both explicit (from outside of Sync),
+ * and implicit (cascaded, from Sync).
+ */
+ public void testDeletingHistory() throws Exception {
+ insertHistoryItem("https://www.mozilla.org", "testGUID");
+ insertHistoryItem("https://www.eff.org", "testGUID2");
+
+ // insert some visits
+ assertEquals(1, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+ new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+ ));
+ assertEquals(1, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+ new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+ ));
+ assertEquals(1, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+ new ContentValues(), History.URL + " = ?", new String[] {"https://www.eff.org"}
+ ));
+
+ Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(3, cursor.getCount());
+ cursor.close();
+
+ // test that corresponding visit records are deleted if Sync isn't involved
+ assertEquals(1,
+ historyClient.delete(historyTestUri, History.URL + " = ?", new String[] {"https://www.mozilla.org"})
+ );
+
+ cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(1, cursor.getCount());
+ cursor.close();
+
+ // test that corresponding visit records are deleted if Sync is involved
+ // insert some more visits
+ ContentValues moz = new ContentValues();
+ moz.put(History.URL, "https://www.mozilla.org");
+ moz.put(History.GUID, "testGUID3");
+ assertEquals(1, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true")
+ .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(),
+ moz, History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+ ));
+ assertEquals(1, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true")
+ .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(),
+ new ContentValues(), History.URL + " = ?", new String[] {"https://www.eff.org"}
+ ));
+
+ assertEquals(1,
+ historyClient.delete(
+ historyTestUri.buildUpon().appendQueryParameter(BrowserContract.PARAM_IS_SYNC, "true").build(),
+ History.URL + " = ?", new String[] {"https://www.eff.org"})
+ );
+
+ cursor = visitsClient.query(visitsTestUri, new String[] {BrowserContract.Visits.HISTORY_GUID}, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(1, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+ assertEquals("testGUID3", cursor.getString(cursor.getColumnIndex(BrowserContract.Visits.HISTORY_GUID)));
+ cursor.close();
+ }
+
+ @Test
+ /**
+ * Test that changes to History GUID are cascaded to individual visits.
+ * See UPDATE CASCADED on Visit's HISTORY_GUID foreign key.
+ */
+ public void testHistoryGUIDUpdate() throws Exception {
+ insertHistoryItem("https://www.mozilla.org", "testGUID");
+ insertHistoryItem("https://www.eff.org", "testGUID2");
+
+ // insert some visits
+ assertEquals(1, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+ new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+ ));
+ assertEquals(1, historyClient.update(
+ historyTestUri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+ new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+ ));
+
+ // change testGUID -> testGUIDNew
+ ContentValues newGuid = new ContentValues();
+ newGuid.put(History.GUID, "testGUIDNew");
+ assertEquals(1, historyClient.update(
+ historyTestUri, newGuid, History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+ ));
+
+ Cursor cursor = visitsClient.query(visitsTestUri, null, BrowserContract.Visits.HISTORY_GUID + " = ?", new String[] {"testGUIDNew"}, null);
+ assertNotNull(cursor);
+ assertEquals(2, cursor.getCount());
+ cursor.close();
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTestBase.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTestBase.java
new file mode 100644
index 000000000..b8ee0bb36
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTestBase.java
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.net.Uri;
+import android.os.RemoteException;
+
+import org.junit.After;
+import org.junit.Before;
+import org.mozilla.gecko.background.db.DelegatingTestContentProvider;
+import org.mozilla.gecko.sync.repositories.android.BrowserContractHelpers;
+import org.robolectric.shadows.ShadowContentResolver;
+
+import java.util.UUID;
+
+public class BrowserProviderHistoryVisitsTestBase {
+ /* package-private */ ShadowContentResolver contentResolver;
+ /* package-private */ ContentProviderClient historyClient;
+ /* package-private */ ContentProviderClient visitsClient;
+ /* package-private */ Uri historyTestUri;
+ /* package-private */ Uri visitsTestUri;
+
+ private BrowserProvider provider;
+
+ @Before
+ public void setUp() throws Exception {
+ provider = new BrowserProvider();
+ provider.onCreate();
+ ShadowContentResolver.registerProvider(BrowserContract.AUTHORITY, new DelegatingTestContentProvider(provider));
+
+ contentResolver = new ShadowContentResolver();
+ historyClient = contentResolver.acquireContentProviderClient(BrowserContractHelpers.HISTORY_CONTENT_URI);
+ visitsClient = contentResolver.acquireContentProviderClient(BrowserContractHelpers.VISITS_CONTENT_URI);
+
+ historyTestUri = testUri(BrowserContract.History.CONTENT_URI);
+ visitsTestUri = testUri(BrowserContract.Visits.CONTENT_URI);
+ }
+
+ @After
+ public void tearDown() {
+ historyClient.release();
+ visitsClient.release();
+ provider.shutdown();
+ }
+
+ /* package-private */ Uri testUri(Uri baseUri) {
+ return baseUri.buildUpon().appendQueryParameter(BrowserContract.PARAM_IS_TEST, "1").build();
+ }
+
+ /* package-private */ Uri insertHistoryItem(String url, String guid) throws RemoteException {
+ return insertHistoryItem(url, guid, System.currentTimeMillis(), null, null);
+ }
+
+ /* package-private */ Uri insertHistoryItem(String url, String guid, Long lastVisited, Integer visitCount) throws RemoteException {
+ return insertHistoryItem(url, guid, lastVisited, visitCount, null);
+ }
+
+ /* package-private */ Uri insertHistoryItem(String url, String guid, Long lastVisited, Integer visitCount, String title) throws RemoteException {
+ ContentValues historyItem = new ContentValues();
+ historyItem.put(BrowserContract.History.URL, url);
+ if (guid != null) {
+ historyItem.put(BrowserContract.History.GUID, guid);
+ }
+ if (visitCount != null) {
+ historyItem.put(BrowserContract.History.VISITS, visitCount);
+ }
+ historyItem.put(BrowserContract.History.DATE_LAST_VISITED, lastVisited);
+ if (title != null) {
+ historyItem.put(BrowserContract.History.TITLE, title);
+ }
+
+ return historyClient.insert(historyTestUri, historyItem);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderVisitsTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderVisitsTest.java
new file mode 100644
index 000000000..928657e82
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderVisitsTest.java
@@ -0,0 +1,301 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.db.BrowserContract.Visits;
+
+@RunWith(TestRunner.class)
+/**
+ * Testing direct interactions with visits through BrowserProvider
+ */
+public class BrowserProviderVisitsTest extends BrowserProviderHistoryVisitsTestBase {
+ @Test
+ /**
+ * Test that default visit parameters are set on insert.
+ */
+ public void testDefaultVisit() throws RemoteException {
+ String url = "https://www.mozilla.org";
+ String guid = "testGuid";
+
+ assertNotNull(insertHistoryItem(url, guid));
+
+ ContentValues visitItem = new ContentValues();
+ Long visitedDate = System.currentTimeMillis();
+ visitItem.put(Visits.HISTORY_GUID, guid);
+ visitItem.put(Visits.DATE_VISITED, visitedDate);
+ Uri insertedVisitUri = visitsClient.insert(visitsTestUri, visitItem);
+ assertNotNull(insertedVisitUri);
+
+ Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ try {
+ assertTrue(cursor.moveToFirst());
+ String insertedGuid = cursor.getString(cursor.getColumnIndex(Visits.HISTORY_GUID));
+ assertEquals(guid, insertedGuid);
+
+ Long insertedDate = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+ assertEquals(visitedDate, insertedDate);
+
+ Integer insertedType = cursor.getInt(cursor.getColumnIndex(Visits.VISIT_TYPE));
+ assertEquals(insertedType, Integer.valueOf(1));
+
+ Integer insertedIsLocal = cursor.getInt(cursor.getColumnIndex(Visits.IS_LOCAL));
+ assertEquals(insertedIsLocal, Integer.valueOf(1));
+ } finally {
+ cursor.close();
+ }
+ }
+
+ @Test
+ /**
+ * Test that we can't insert visit for non-existing GUID.
+ */
+ public void testMissingHistoryGuid() throws RemoteException {
+ ContentValues visitItem = new ContentValues();
+ visitItem.put(Visits.HISTORY_GUID, "blah");
+ visitItem.put(Visits.DATE_VISITED, System.currentTimeMillis());
+ assertNull(visitsClient.insert(visitsTestUri, visitItem));
+ }
+
+ @Test
+ /**
+ * Test that visit insert uses non-conflict insert.
+ */
+ public void testNonConflictInsert() throws RemoteException {
+ String url = "https://www.mozilla.org";
+ String guid = "testGuid";
+
+ assertNotNull(insertHistoryItem(url, guid));
+
+ ContentValues visitItem = new ContentValues();
+ Long visitedDate = System.currentTimeMillis();
+ visitItem.put(Visits.HISTORY_GUID, guid);
+ visitItem.put(Visits.DATE_VISITED, visitedDate);
+ Uri insertedVisitUri = visitsClient.insert(visitsTestUri, visitItem);
+ assertNotNull(insertedVisitUri);
+
+ Uri insertedVisitUri2 = visitsClient.insert(visitsTestUri, visitItem);
+ assertEquals(insertedVisitUri, insertedVisitUri2);
+ }
+
+ @Test
+ /**
+ * Test that non-default visit parameters won't get overridden.
+ */
+ public void testNonDefaultInsert() throws RemoteException {
+ assertNotNull(insertHistoryItem("https://www.mozilla.org", "testGuid"));
+
+ Integer typeToInsert = 5;
+ Integer isLocalToInsert = 0;
+
+ ContentValues visitItem = new ContentValues();
+ visitItem.put(Visits.HISTORY_GUID, "testGuid");
+ visitItem.put(Visits.DATE_VISITED, System.currentTimeMillis());
+ visitItem.put(Visits.VISIT_TYPE, typeToInsert);
+ visitItem.put(Visits.IS_LOCAL, isLocalToInsert);
+
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+ Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ try {
+ assertTrue(cursor.moveToFirst());
+
+ Integer insertedVisitType = cursor.getInt(cursor.getColumnIndex(Visits.VISIT_TYPE));
+ assertEquals(typeToInsert, insertedVisitType);
+
+ Integer insertedIsLocal = cursor.getInt(cursor.getColumnIndex(Visits.IS_LOCAL));
+ assertEquals(isLocalToInsert, insertedIsLocal);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ @Test
+ /**
+ * Test that default sorting order (DATE_VISITED DESC) is set if we don't specify any sorting params
+ */
+ public void testDefaultSortingOrder() throws RemoteException {
+ assertNotNull(insertHistoryItem("https://www.mozilla.org", "testGuid"));
+
+ Long time1 = System.currentTimeMillis();
+ Long time2 = time1 + 100;
+ Long time3 = time1 + 200;
+
+ ContentValues visitItem = new ContentValues();
+ visitItem.put(Visits.DATE_VISITED, time1);
+ visitItem.put(Visits.HISTORY_GUID, "testGuid");
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+ visitItem.put(Visits.DATE_VISITED, time3);
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+ visitItem.put(Visits.DATE_VISITED, time2);
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+ Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ try {
+ assertEquals(3, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+
+ Long timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+ assertEquals(time3, timeInserted);
+
+ cursor.moveToNext();
+
+ timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+ assertEquals(time2, timeInserted);
+
+ cursor.moveToNext();
+
+ timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+ assertEquals(time1, timeInserted);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ @Test
+ /**
+ * Test that if we pass sorting params, they're not overridden
+ */
+ public void testNonDefaultSortingOrder() throws RemoteException {
+ assertNotNull(insertHistoryItem("https://www.mozilla.org", "testGuid"));
+
+ Long time1 = System.currentTimeMillis();
+ Long time2 = time1 + 100;
+ Long time3 = time1 + 200;
+
+ ContentValues visitItem = new ContentValues();
+ visitItem.put(Visits.DATE_VISITED, time1);
+ visitItem.put(Visits.HISTORY_GUID, "testGuid");
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+ visitItem.put(Visits.DATE_VISITED, time3);
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+ visitItem.put(Visits.DATE_VISITED, time2);
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+ Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, Visits.DATE_VISITED + " ASC");
+ assertNotNull(cursor);
+ assertEquals(3, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+
+ Long timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+ assertEquals(time1, timeInserted);
+
+ cursor.moveToNext();
+
+ timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+ assertEquals(time2, timeInserted);
+
+ cursor.moveToNext();
+
+ timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+ assertEquals(time3, timeInserted);
+
+ cursor.close();
+ }
+
+ @Test
+ /**
+ * Tests deletion of all visits, and by some selection (GUID, IS_LOCAL)
+ */
+ public void testVisitDeletion() throws RemoteException {
+ assertNotNull(insertHistoryItem("https://www.mozilla.org", "testGuid"));
+ assertNotNull(insertHistoryItem("https://www.eff.org", "testGuid2"));
+
+ Long time1 = System.currentTimeMillis();
+
+ ContentValues visitItem = new ContentValues();
+ visitItem.put(Visits.DATE_VISITED, time1);
+ visitItem.put(Visits.HISTORY_GUID, "testGuid");
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+ visitItem = new ContentValues();
+ visitItem.put(Visits.DATE_VISITED, time1 + 100);
+ visitItem.put(Visits.HISTORY_GUID, "testGuid");
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+ ContentValues visitItem2 = new ContentValues();
+ visitItem2.put(Visits.DATE_VISITED, time1);
+ visitItem2.put(Visits.HISTORY_GUID, "testGuid2");
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem2));
+
+ Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(3, cursor.getCount());
+ cursor.close();
+
+ assertEquals(3, visitsClient.delete(visitsTestUri, null, null));
+
+ cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(0, cursor.getCount());
+ cursor.close();
+
+ // test selective deletion - by IS_LOCAL
+ visitItem = new ContentValues();
+ visitItem.put(Visits.DATE_VISITED, time1);
+ visitItem.put(Visits.HISTORY_GUID, "testGuid");
+ visitItem.put(Visits.IS_LOCAL, 0);
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+ visitItem = new ContentValues();
+ visitItem.put(Visits.DATE_VISITED, time1 + 100);
+ visitItem.put(Visits.HISTORY_GUID, "testGuid");
+ visitItem.put(Visits.IS_LOCAL, 1);
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+ visitItem2 = new ContentValues();
+ visitItem2.put(Visits.DATE_VISITED, time1);
+ visitItem2.put(Visits.HISTORY_GUID, "testGuid2");
+ visitItem2.put(Visits.IS_LOCAL, 0);
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem2));
+
+ cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(3, cursor.getCount());
+ cursor.close();
+
+ assertEquals(2,
+ visitsClient.delete(visitsTestUri, Visits.IS_LOCAL + " = ?", new String[]{"0"}));
+ cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(1, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+ assertEquals(time1 + 100, cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED)));
+ assertEquals("testGuid", cursor.getString(cursor.getColumnIndex(Visits.HISTORY_GUID)));
+ assertEquals(1, cursor.getInt(cursor.getColumnIndex(Visits.IS_LOCAL)));
+ cursor.close();
+
+ // test selective deletion - by HISTORY_GUID
+ assertNotNull(visitsClient.insert(visitsTestUri, visitItem2));
+ cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(2, cursor.getCount());
+ cursor.close();
+
+ assertEquals(1,
+ visitsClient.delete(visitsTestUri, Visits.HISTORY_GUID + " = ?", new String[]{"testGuid"}));
+ cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+ assertNotNull(cursor);
+ assertEquals(1, cursor.getCount());
+ assertTrue(cursor.moveToFirst());
+ assertEquals("testGuid2", cursor.getString(cursor.getColumnIndex(Visits.HISTORY_GUID)));
+ cursor.close();
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/distribution/TestReferrerDescriptor.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/distribution/TestReferrerDescriptor.java
new file mode 100644
index 000000000..9e553cf44
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/distribution/TestReferrerDescriptor.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.distribution;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+@RunWith(TestRunner.class)
+public class TestReferrerDescriptor {
+ @Test
+ public void testReferrerDescriptor() {
+ String referrerString1 = "utm_source%3Dsource%26utm_content%3Dcontent%26utm_campaign%3Dcampaign%26utm_medium%3Dmedium%26utm_term%3Dterm";
+ String referrerString2 = "utm_source=source&utm_content=content&utm_campaign=campaign&utm_medium=medium&utm_term=term";
+ ReferrerDescriptor referrer1 = new ReferrerDescriptor(referrerString1);
+ Assert.assertNotNull(referrer1);
+ Assert.assertEquals(referrer1.source, "source");
+ Assert.assertEquals(referrer1.content, "content");
+ Assert.assertEquals(referrer1.campaign, "campaign");
+ Assert.assertEquals(referrer1.medium, "medium");
+ Assert.assertEquals(referrer1.term, "term");
+ ReferrerDescriptor referrer2 = new ReferrerDescriptor(referrerString2);
+ Assert.assertNotNull(referrer2);
+ Assert.assertEquals(referrer2.source, "source");
+ Assert.assertEquals(referrer2.content, "content");
+ Assert.assertEquals(referrer2.campaign, "campaign");
+ Assert.assertEquals(referrer2.medium, "medium");
+ Assert.assertEquals(referrer2.term, "term");
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestDownloadAction.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestDownloadAction.java
new file mode 100644
index 000000000..2252c90c8
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestDownloadAction.java
@@ -0,0 +1,607 @@
+/* -*- 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.dlc;
+
+import android.content.Context;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.mockito.Matchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * DownloadAction: Download content that has been scheduled during "study" or "verify".
+ */
+@RunWith(TestRunner.class)
+public class TestDownloadAction {
+ private static final String TEST_URL = "http://example.org";
+
+ private static final int STATUS_OK = 200;
+ private static final int STATUS_PARTIAL_CONTENT = 206;
+
+ /**
+ * Scenario: The current network is metered.
+ *
+ * Verify that:
+ * * No download is performed on a metered network
+ */
+ @Test
+ public void testNothingIsDoneOnMeteredNetwork() throws Exception {
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(true).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
+
+ action.perform(RuntimeEnvironment.application, null);
+
+ verify(action, never()).buildHttpURLConnection(anyString());
+ verify(action, never()).download(anyString(), any(File.class));
+ }
+
+ /**
+ * Scenario: No (connected) network is available.
+ *
+ * Verify that:
+ * * No download is performed
+ */
+ @Test
+ public void testNothingIsDoneIfNoNetworkIsAvailable() throws Exception {
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(false).when(action).isConnectedToNetwork(RuntimeEnvironment.application);
+
+ action.perform(RuntimeEnvironment.application, null);
+
+ verify(action, never()).isActiveNetworkMetered(any(Context.class));
+ verify(action, never()).buildHttpURLConnection(anyString());
+ verify(action, never()).download(anyString(), any(File.class));
+ }
+
+ /**
+ * Scenario: Content is scheduled for download but already exists locally (with correct checksum).
+ *
+ * Verify that:
+ * * No download is performed for existing file
+ * * Content is marked as downloaded in the catalog
+ */
+ @Test
+ public void testExistingAndVerifiedFilesAreNotDownloadedAgain() throws Exception {
+ DownloadContent content = new DownloadContentBuilder().build();
+
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
+
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
+
+ File file = mock(File.class);
+ doReturn(true).when(file).exists();
+ doReturn(file).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
+ doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+ doReturn(true).when(action).verify(eq(file), anyString());
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(action, never()).download(anyString(), any(File.class));
+ verify(catalog).markAsDownloaded(content);
+ }
+
+ /**
+ * Scenario: Server returns a server error (HTTP 500).
+ *
+ * Verify that:
+ * * Situation is treated as recoverable (RecoverableDownloadContentException)
+ */
+ @Test(expected=BaseAction.RecoverableDownloadContentException.class)
+ public void testServerErrorsAreRecoverable() throws Exception {
+ HttpURLConnection connection = mockHttpURLConnection(500, "");
+
+ File temporaryFile = mock(File.class);
+ doReturn(false).when(temporaryFile).exists();
+
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(connection).when(action).buildHttpURLConnection(anyString());
+ action.download(TEST_URL, temporaryFile);
+
+ verify(connection).getInputStream();
+ }
+
+ /**
+ * Scenario: Server returns a client error (HTTP 404).
+ *
+ * Verify that:
+ * * Situation is treated as unrecoverable (UnrecoverableDownloadContentException)
+ */
+ @Test(expected=BaseAction.UnrecoverableDownloadContentException.class)
+ public void testClientErrorsAreUnrecoverable() throws Exception {
+ HttpURLConnection connection = mockHttpURLConnection(404, "");
+
+ File temporaryFile = mock(File.class);
+ doReturn(false).when(temporaryFile).exists();
+
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(connection).when(action).buildHttpURLConnection(anyString());
+ action.download(TEST_URL, temporaryFile);
+
+ verify(connection).getInputStream();
+ }
+
+ /**
+ * Scenario: A successful download has been performed.
+ *
+ * Verify that:
+ * * The content will be extracted to the destination
+ * * The content is marked as downloaded in the catalog
+ */
+ @Test
+ public void testSuccessfulDownloadsAreMarkedAsDownloaded() throws Exception {
+ DownloadContent content = new DownloadContentBuilder()
+ .setKind(DownloadContent.KIND_FONT)
+ .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
+ .build();
+
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
+
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
+
+ File file = mockNotExistingFile();
+ doReturn(file).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
+ doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+
+ doReturn(false).when(action).verify(eq(file), anyString());
+ doNothing().when(action).download(anyString(), eq(file));
+ doReturn(true).when(action).verify(eq(file), anyString());
+ doNothing().when(action).extract(eq(file), eq(file), anyString());
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(action).download(anyString(), eq(file));
+ verify(action).extract(eq(file), eq(file), anyString());
+ verify(catalog).markAsDownloaded(content);
+ }
+
+ /**
+ * Scenario: Pretend a partially downloaded file already exists.
+ *
+ * Verify that:
+ * * Range header is set in request
+ * * Content will be appended to existing file
+ * * Content will be marked as downloaded in catalog
+ */
+ @Test
+ public void testResumingDownloadFromExistingFile() throws Exception {
+ DownloadContent content = new DownloadContentBuilder()
+ .setKind(DownloadContent.KIND_FONT)
+ .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
+ .setSize(4223)
+ .build();
+
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
+
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
+
+ File temporaryFile = mockFileWithSize(1337L);
+ doReturn(temporaryFile).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
+
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ doReturn(outputStream).when(action).openFile(eq(temporaryFile), anyBoolean());
+
+ HttpURLConnection connection = mockHttpURLConnection(STATUS_PARTIAL_CONTENT, "HelloWorld");
+ doReturn(connection).when(action).buildHttpURLConnection(anyString());
+
+ File destinationFile = mockNotExistingFile();
+ doReturn(destinationFile).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+
+ doReturn(true).when(action).verify(eq(temporaryFile), anyString());
+ doNothing().when(action).extract(eq(temporaryFile), eq(destinationFile), anyString());
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(connection).getInputStream();
+ verify(connection).setRequestProperty("Range", "bytes=1337-");
+
+ Assert.assertEquals("HelloWorld", new String(outputStream.toByteArray(), "UTF-8"));
+
+ verify(action).openFile(eq(temporaryFile), eq(true));
+ verify(catalog).markAsDownloaded(content);
+ verify(temporaryFile).delete();
+ }
+
+ /**
+ * Scenario: Download fails with IOException.
+ *
+ * Verify that:
+ * * Partially downloaded file will not be deleted
+ * * Content will not be marked as downloaded in catalog
+ */
+ @Test
+ public void testTemporaryFileIsNotDeletedAfterDownloadAborted() throws Exception {
+ DownloadContent content = new DownloadContentBuilder()
+ .setKind(DownloadContent.KIND_FONT)
+ .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
+ .setSize(4223)
+ .build();
+
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
+
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
+
+ File temporaryFile = mockFileWithSize(1337L);
+ doReturn(temporaryFile).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
+
+ ByteArrayOutputStream outputStream = spy(new ByteArrayOutputStream());
+ doReturn(outputStream).when(action).openFile(eq(temporaryFile), anyBoolean());
+ doThrow(IOException.class).when(outputStream).write(any(byte[].class), anyInt(), anyInt());
+
+ HttpURLConnection connection = mockHttpURLConnection(STATUS_PARTIAL_CONTENT, "HelloWorld");
+ doReturn(connection).when(action).buildHttpURLConnection(anyString());
+
+ doReturn(mockNotExistingFile()).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(catalog, never()).markAsDownloaded(content);
+ verify(action, never()).verify(any(File.class), anyString());
+ verify(temporaryFile, never()).delete();
+ }
+
+ /**
+ * Scenario: Partially downloaded file is already complete.
+ *
+ * Verify that:
+ * * No download request is made
+ * * File is treated as completed and will be verified and extracted
+ * * Content is marked as downloaded in catalog
+ */
+ @Test
+ public void testNoRequestIsSentIfFileIsAlreadyComplete() throws Exception {
+ DownloadContent content = new DownloadContentBuilder()
+ .setKind(DownloadContent.KIND_FONT)
+ .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
+ .setSize(1337L)
+ .build();
+
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
+
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
+
+ File temporaryFile = mockFileWithSize(1337L);
+ doReturn(temporaryFile).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
+
+ File destinationFile = mockNotExistingFile();
+ doReturn(destinationFile).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+
+ doReturn(true).when(action).verify(eq(temporaryFile), anyString());
+ doNothing().when(action).extract(eq(temporaryFile), eq(destinationFile), anyString());
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(action, never()).download(anyString(), eq(temporaryFile));
+ verify(action).verify(eq(temporaryFile), anyString());
+ verify(action).extract(eq(temporaryFile), eq(destinationFile), anyString());
+ verify(catalog).markAsDownloaded(content);
+ }
+
+ /**
+ * Scenario: Download is completed but verification (checksum) failed.
+ *
+ * Verify that:
+ * * Downloaded file is deleted
+ * * File will not be extracted
+ * * Content is not marked as downloaded in the catalog
+ */
+ @Test
+ public void testTemporaryFileWillBeDeletedIfVerificationFails() throws Exception {
+ DownloadContent content = new DownloadContentBuilder()
+ .setKind(DownloadContent.KIND_FONT)
+ .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
+ .setSize(1337L)
+ .build();
+
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
+
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
+ doNothing().when(action).download(anyString(), any(File.class));
+ doReturn(false).when(action).verify(any(File.class), anyString());
+
+ File temporaryFile = mockNotExistingFile();
+ doReturn(temporaryFile).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
+
+ File destinationFile = mockNotExistingFile();
+ doReturn(destinationFile).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(temporaryFile).delete();
+ verify(action, never()).extract(any(File.class), any(File.class), anyString());
+ verify(catalog, never()).markAsDownloaded(content);
+ }
+
+ /**
+ * Scenario: Not enough storage space for content is available.
+ *
+ * Verify that:
+ * * No download will per performed
+ */
+ @Test
+ public void testNoDownloadIsPerformedIfNotEnoughStorageIsAvailable() throws Exception {
+ DownloadContent content = createFontWithSize(1337L);
+ DownloadContentCatalog catalog = mockCatalogWithScheduledDownloads(content);
+
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
+ doReturn(true).when(action).isConnectedToNetwork(RuntimeEnvironment.application);
+
+ File temporaryFile = mockNotExistingFile();
+ doReturn(temporaryFile).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
+
+ File destinationFile = mockNotExistingFile();
+ doReturn(destinationFile).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+
+ doReturn(true).when(action).hasEnoughDiskSpace(content, destinationFile, temporaryFile);
+
+ verify(action, never()).buildHttpURLConnection(anyString());
+ verify(action, never()).download(anyString(), any(File.class));
+ verify(action, never()).verify(any(File.class), anyString());
+ verify(catalog, never()).markAsDownloaded(content);
+ }
+
+ /**
+ * Scenario: Not enough storage space for temporary file available.
+ *
+ * Verify that:
+ * * hasEnoughDiskSpace() returns false
+ */
+ @Test
+ public void testWithNotEnoughSpaceForTemporaryFile() throws Exception{
+ DownloadContent content = createFontWithSize(2048);
+ File destinationFile = mockNotExistingFile();
+ File temporaryFile = mockNotExistingFileWithUsableSpace(1024);
+
+ DownloadAction action = new DownloadAction(null);
+ Assert.assertFalse(action.hasEnoughDiskSpace(content, destinationFile, temporaryFile));
+ }
+
+ /**
+ * Scenario: Not enough storage space for destination file available.
+ *
+ * Verify that:
+ * * hasEnoughDiskSpace() returns false
+ */
+ @Test
+ public void testWithNotEnoughSpaceForDestinationFile() throws Exception {
+ DownloadContent content = createFontWithSize(2048);
+ File destinationFile = mockNotExistingFileWithUsableSpace(1024);
+ File temporaryFile = mockNotExistingFile();
+
+ DownloadAction action = new DownloadAction(null);
+ Assert.assertFalse(action.hasEnoughDiskSpace(content, destinationFile, temporaryFile));
+ }
+
+ /**
+ * Scenario: Enough storage space for temporary and destination file available.
+ *
+ * Verify that:
+ * * hasEnoughDiskSpace() returns true
+ */
+ @Test
+ public void testWithEnoughSpaceForEverything() throws Exception {
+ DownloadContent content = createFontWithSize(2048);
+ File destinationFile = mockNotExistingFileWithUsableSpace(4096);
+ File temporaryFile = mockNotExistingFileWithUsableSpace(4096);
+
+ DownloadAction action = new DownloadAction(null);
+ Assert.assertTrue(action.hasEnoughDiskSpace(content, destinationFile, temporaryFile));
+ }
+
+ /**
+ * Scenario: Download failed with network I/O error.
+ *
+ * Verify that:
+ * * Error is not counted as failure
+ */
+ @Test
+ public void testNetworkErrorIsNotCountedAsFailure() throws Exception {
+ DownloadContent content = createFont();
+ DownloadContentCatalog catalog = mockCatalogWithScheduledDownloads(content);
+
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(true).when(action).isConnectedToNetwork(RuntimeEnvironment.application);
+ doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
+ doReturn(mockNotExistingFile()).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
+ doReturn(mockNotExistingFile()).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+ doReturn(true).when(action).hasEnoughDiskSpace(eq(content), any(File.class), any(File.class));
+
+ HttpURLConnection connection = mockHttpURLConnection(STATUS_OK, "");
+ doThrow(IOException.class).when(connection).getInputStream();
+ doReturn(connection).when(action).buildHttpURLConnection(anyString());
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(catalog, never()).rememberFailure(eq(content), anyInt());
+ verify(catalog, never()).markAsDownloaded(content);
+ }
+
+ /**
+ * Scenario: Disk IO Error when extracting file.
+ *
+ * Verify that:
+ * * Error is counted as failure
+ * * After multiple errors the content is marked as permanently failed
+ */
+ @Test
+ public void testDiskIOErrorIsCountedAsFailure() throws Exception {
+ DownloadContent content = createFont();
+ DownloadContentCatalog catalog = mockCatalogWithScheduledDownloads(content);
+ doCallRealMethod().when(catalog).rememberFailure(eq(content), anyInt());
+ doCallRealMethod().when(catalog).markAsPermanentlyFailed(content);
+
+ Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
+
+ DownloadAction action = spy(new DownloadAction(null));
+ doReturn(true).when(action).isConnectedToNetwork(RuntimeEnvironment.application);
+ doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
+ doReturn(mockNotExistingFile()).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
+ doReturn(mockNotExistingFile()).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+ doReturn(true).when(action).hasEnoughDiskSpace(eq(content), any(File.class), any(File.class));
+ doNothing().when(action).download(anyString(), any(File.class));
+ doReturn(true).when(action).verify(any(File.class), anyString());
+
+ File destinationFile = mock(File.class);
+ doReturn(false).when(destinationFile).exists();
+ File parentFile = mock(File.class);
+ doReturn(false).when(parentFile).mkdirs();
+ doReturn(false).when(parentFile).exists();
+ doReturn(parentFile).when(destinationFile).getParentFile();
+ doReturn(destinationFile).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+
+ for (int i = 0; i < 10; i++) {
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
+ }
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ Assert.assertEquals(DownloadContent.STATE_FAILED, content.getState());
+ verify(catalog, times(11)).rememberFailure(eq(content), anyInt());
+ }
+
+ /**
+ * Scenario: If the file to be downloaded is of kind - "hyphenation"
+ *
+ * Verify that:
+ * * isHyphenationDictionary returns true for a download content with kind "hyphenation"
+ * * isHyphenationDictionary returns false for a download content with unknown/different kind like "Font"
+ */
+ @Test
+ public void testIsHyphenationDictionary() throws Exception {
+ DownloadContent hyphenationContent = createHyphenationDictionary();
+ Assert.assertTrue(hyphenationContent.isHyphenationDictionary());
+ DownloadContent fontContent = createFont();
+ Assert.assertFalse(fontContent.isHyphenationDictionary());
+ DownloadContent unknownContent = createUnknownContent(1024L);
+ Assert.assertFalse(unknownContent.isHyphenationDictionary());
+ }
+
+ /**
+ * Scenario: If the content to be downloaded is known
+ *
+ * Verify that:
+ * * isKnownContent returns true for a downloadable content with a known kind and type.
+ * * isKnownContent returns false for a downloadable content with unknown kind and type.
+ */
+ @Test
+ public void testIsKnownContent() throws Exception {
+ DownloadContent fontContent = createFontWithSize(1024L);
+ DownloadContent hyphenationContent = createHyphenationDictionaryWithSize(1024L);
+ DownloadContent unknownContent = createUnknownContent(1024L);
+ DownloadContent contentWithUnknownType = createContentWithoutType(1024L);
+
+ Assert.assertTrue(fontContent.isKnownContent());
+ Assert.assertTrue(hyphenationContent.isKnownContent());
+ Assert.assertFalse(unknownContent.isKnownContent());
+ Assert.assertFalse(contentWithUnknownType.isKnownContent());
+ }
+
+ private DownloadContent createUnknownContent(long size) {
+ return new DownloadContentBuilder()
+ .setSize(size)
+ .build();
+ }
+
+ private DownloadContent createContentWithoutType(long size) {
+ return new DownloadContentBuilder()
+ .setKind(DownloadContent.KIND_HYPHENATION_DICTIONARY)
+ .setSize(size)
+ .build();
+ }
+
+ private DownloadContent createFont() {
+ return createFontWithSize(102400L);
+ }
+
+ private DownloadContent createFontWithSize(long size) {
+ return new DownloadContentBuilder()
+ .setKind(DownloadContent.KIND_FONT)
+ .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
+ .setSize(size)
+ .build();
+ }
+
+ private DownloadContent createHyphenationDictionary() {
+ return createHyphenationDictionaryWithSize(102400L);
+ }
+
+ private DownloadContent createHyphenationDictionaryWithSize(long size) {
+ return new DownloadContentBuilder()
+ .setKind(DownloadContent.KIND_HYPHENATION_DICTIONARY)
+ .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
+ .setSize(size)
+ .build();
+ }
+
+ private DownloadContentCatalog mockCatalogWithScheduledDownloads(DownloadContent... content) {
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ doReturn(Arrays.asList(content)).when(catalog).getScheduledDownloads();
+ return catalog;
+ }
+
+ private static File mockNotExistingFile() {
+ return mockFileWithUsableSpace(false, 0, Long.MAX_VALUE);
+ }
+
+ private static File mockNotExistingFileWithUsableSpace(long usableSpace) {
+ return mockFileWithUsableSpace(false, 0, usableSpace);
+ }
+
+ private static File mockFileWithSize(long length) {
+ return mockFileWithUsableSpace(true, length, Long.MAX_VALUE);
+ }
+
+ private static File mockFileWithUsableSpace(boolean exists, long length, long usableSpace) {
+ File file = mock(File.class);
+ doReturn(exists).when(file).exists();
+ doReturn(length).when(file).length();
+
+ File parentFile = mock(File.class);
+ doReturn(usableSpace).when(parentFile).getUsableSpace();
+ doReturn(parentFile).when(file).getParentFile();
+
+ return file;
+ }
+
+ private static HttpURLConnection mockHttpURLConnection(int statusCode, String content) throws Exception {
+ HttpURLConnection connection = mock(HttpURLConnection.class);
+
+ doReturn(statusCode).when(connection).getResponseCode();
+ doReturn(new ByteArrayInputStream(content.getBytes("UTF-8"))).when(connection).getInputStream();
+
+ return connection;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestStudyAction.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestStudyAction.java
new file mode 100644
index 000000000..6b2ce83df
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestStudyAction.java
@@ -0,0 +1,119 @@
+/* -*- 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.dlc;
+
+import android.content.Context;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * StudyAction: Scan the catalog for "new" content available for download.
+ */
+@RunWith(TestRunner.class)
+public class TestStudyAction {
+ /**
+ * Scenario: Catalog is empty.
+ *
+ * Verify that:
+ * * No download is scheduled
+ * * Download action is not started
+ */
+ @Test
+ public void testPerformWithEmptyCatalog() {
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ when(catalog.getContentToStudy()).thenReturn(new ArrayList<DownloadContent>());
+
+ StudyAction action = spy(new StudyAction());
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(catalog).getContentToStudy();
+ verify(catalog, never()).markAsDownloaded(any(DownloadContent.class));
+ verify(action, never()).startDownloads(any(Context.class));
+ }
+
+ /**
+ * Scenario: Catalog contains two items that have not been downloaded yet.
+ *
+ * Verify that:
+ * * Both items are scheduled to be downloaded
+ */
+ @Test
+ public void testPerformWithNewContent() {
+ DownloadContent content1 = new DownloadContentBuilder()
+ .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
+ .setKind(DownloadContent.KIND_FONT)
+ .build();
+ DownloadContent content2 = new DownloadContentBuilder()
+ .setType(DownloadContent.TYPE_ASSET_ARCHIVE)
+ .setKind(DownloadContent.KIND_FONT)
+ .build();
+
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ when(catalog.getContentToStudy()).thenReturn(Arrays.asList(content1, content2));
+
+ StudyAction action = spy(new StudyAction());
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(catalog).scheduleDownload(content1);
+ verify(catalog).scheduleDownload(content2);
+ }
+
+ /**
+ * Scenario: Catalog contains item that are scheduled for download.
+ *
+ * Verify that:
+ * * Download action is started
+ */
+ @Test
+ public void testStartingDownloadsAfterScheduling() {
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ when(catalog.hasScheduledDownloads()).thenReturn(true);
+
+ StudyAction action = spy(new StudyAction());
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(action).startDownloads(any(Context.class));
+ }
+
+ /**
+ * Scenario: Catalog contains unknown content.
+ *
+ * Verify that:
+ * * Unknown content is not scheduled for download.
+ */
+ @Test
+ public void testPerformWithUnknownContent() {
+ DownloadContent content = new DownloadContentBuilder()
+ .setType("Unknown-Type")
+ .setKind("Unknown-Kind")
+ .build();
+
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ when(catalog.getContentToStudy()).thenReturn(Collections.singletonList(content));
+
+ StudyAction action = spy(new StudyAction());
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(catalog, never()).scheduleDownload(content);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestSyncAction.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestSyncAction.java
new file mode 100644
index 000000000..1e494975e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestSyncAction.java
@@ -0,0 +1,276 @@
+/* -*- 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.dlc;
+
+import android.content.Context;
+import android.support.v4.util.ArrayMap;
+import android.support.v4.util.AtomicFile;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.mozilla.gecko.util.IOUtils;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.List;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyLong;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+/**
+ * SyncAction: Synchronize catalog from a (mocked) Kinto instance.
+ */
+@RunWith(TestRunner.class)
+public class TestSyncAction {
+ /**
+ * Scenario: The server returns an empty record set.
+ */
+ @Test
+ public void testEmptyResult() throws Exception {
+ SyncAction action = spy(new SyncAction());
+ doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+ doReturn(new JSONArray()).when(action).fetchRawCatalog(anyLong());
+
+ action.perform(RuntimeEnvironment.application, mockCatalog());
+
+ verify(action, never()).createContent(anyCatalog(), anyJSONObject());
+ verify(action, never()).updateContent(anyCatalog(), anyJSONObject(), anyContent());
+ verify(action, never()).deleteContent(anyCatalog(), anyString());
+
+ verify(action, never()).startStudyAction(anyContext());
+ }
+
+ /**
+ * Scenario: The server returns an item that is not in the catalog yet.
+ */
+ @Test
+ public void testAddingNewContent() throws Exception {
+ SyncAction action = spy(new SyncAction());
+ doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+ doReturn(fromFile("dlc_sync_single_font.json")).when(action).fetchRawCatalog(anyLong());
+
+ DownloadContentCatalog catalog = mockCatalog();
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ // A new content item has been created
+ verify(action).createContent(anyCatalog(), anyJSONObject());
+
+ // No content item has been updated or deleted
+ verify(action, never()).updateContent(anyCatalog(), anyJSONObject(), anyContent());
+ verify(action, never()).deleteContent(anyCatalog(), anyString());
+
+ // A new item has been added to the catalog
+ ArgumentCaptor<DownloadContent> captor = ArgumentCaptor.forClass(DownloadContent.class);
+ verify(catalog).add(captor.capture());
+
+ // The item matches the values from the server response
+ DownloadContent content = captor.getValue();
+ Assert.assertEquals("c906275c-3747-fe27-426f-6187526a6f06", content.getId());
+ Assert.assertEquals("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067", content.getChecksum());
+ Assert.assertEquals("960be4fc5a92c1dc488582b215d5d75429fd4ffbee463105d29992cd792a912e", content.getDownloadChecksum());
+ Assert.assertEquals("CharisSILCompact-R.ttf", content.getFilename());
+ Assert.assertEquals(DownloadContent.KIND_FONT, content.getKind());
+ Assert.assertEquals("/attachments/0d28a72d-a51f-46f8-9e5a-f95c61de904e.gz", content.getLocation());
+ Assert.assertEquals(DownloadContent.TYPE_ASSET_ARCHIVE, content.getType());
+ Assert.assertEquals(1455710632607L, content.getLastModified());
+ Assert.assertEquals(1727656L, content.getSize());
+ Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
+ }
+
+ /**
+ * Scenario: The catalog is using the old format, we want to make sure we abort cleanly.
+ */
+ @Test
+ public void testUpdatingWithOldCatalog() throws Exception{
+ SyncAction action = spy(new SyncAction());
+ doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+ doReturn(fromFile("dlc_sync_old_format.json")).when(action).fetchRawCatalog(anyLong());
+
+ DownloadContent existingContent = createTestContent("c906275c-3747-fe27-426f-6187526a6f06");
+ DownloadContentCatalog catalog = spy(new MockedContentCatalog(existingContent));
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ // make sure nothing was done
+ verify(action, never()).createContent(anyCatalog(), anyJSONObject());
+ verify(action, never()).updateContent(anyCatalog(), anyJSONObject(), anyContent());
+ verify(action, never()).deleteContent(anyCatalog(), anyString());
+ verify(action, never()).startStudyAction(anyContext());
+ }
+
+
+ /**
+ * Scenario: The catalog contains one item and the server returns a new version.
+ */
+ @Test
+ public void testUpdatingExistingContent() throws Exception{
+ SyncAction action = spy(new SyncAction());
+ doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+ doReturn(fromFile("dlc_sync_single_font.json")).when(action).fetchRawCatalog(anyLong());
+
+ DownloadContent existingContent = createTestContent("c906275c-3747-fe27-426f-6187526a6f06");
+ DownloadContentCatalog catalog = spy(new MockedContentCatalog(existingContent));
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ // A content item has been updated
+ verify(action).updateContent(anyCatalog(), anyJSONObject(), eq(existingContent));
+
+ // No content item has been created or deleted
+ verify(action, never()).createContent(anyCatalog(), anyJSONObject());
+ verify(action, never()).deleteContent(anyCatalog(), anyString());
+
+ // An item has been updated in the catalog
+ ArgumentCaptor<DownloadContent> captor = ArgumentCaptor.forClass(DownloadContent.class);
+ verify(catalog).update(captor.capture());
+
+ // The item has the new values from the sever response
+ DownloadContent content = captor.getValue();
+ Assert.assertEquals("c906275c-3747-fe27-426f-6187526a6f06", content.getId());
+ Assert.assertEquals("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067", content.getChecksum());
+ Assert.assertEquals("960be4fc5a92c1dc488582b215d5d75429fd4ffbee463105d29992cd792a912e", content.getDownloadChecksum());
+ Assert.assertEquals("CharisSILCompact-R.ttf", content.getFilename());
+ Assert.assertEquals(DownloadContent.KIND_FONT, content.getKind());
+ Assert.assertEquals("/attachments/0d28a72d-a51f-46f8-9e5a-f95c61de904e.gz", content.getLocation());
+ Assert.assertEquals(DownloadContent.TYPE_ASSET_ARCHIVE, content.getType());
+ Assert.assertEquals(1455710632607L, content.getLastModified());
+ Assert.assertEquals(1727656L, content.getSize());
+ Assert.assertEquals(DownloadContent.STATE_UPDATED, content.getState());
+ }
+
+ /**
+ * Scenario: Catalog contains one item and the server returns that it has been deleted.
+ */
+ @Test
+ public void testDeletingExistingContent() throws Exception {
+ SyncAction action = spy(new SyncAction());
+ doReturn(true).when(action).isSyncEnabledForClient(RuntimeEnvironment.application);
+ doReturn(fromFile("dlc_sync_deleted_item.json")).when(action).fetchRawCatalog(anyLong());
+
+ final String id = "c906275c-3747-fe27-426f-6187526a6f06";
+ DownloadContent existingContent = createTestContent(id);
+ DownloadContentCatalog catalog = spy(new MockedContentCatalog(existingContent));
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ // A content item has been deleted
+ verify(action).deleteContent(anyCatalog(), eq(id));
+
+ // No content item has been created or updated
+ verify(action, never()).createContent(anyCatalog(), anyJSONObject());
+ verify(action, never()).updateContent(anyCatalog(), anyJSONObject(), anyContent());
+
+ // An item has been marked for deletion in the catalog
+ ArgumentCaptor<DownloadContent> captor = ArgumentCaptor.forClass(DownloadContent.class);
+ verify(catalog).markAsDeleted(captor.capture());
+
+ DownloadContent content = captor.getValue();
+ Assert.assertEquals(id, content.getId());
+
+ List<DownloadContent> contentToDelete = catalog.getContentToDelete();
+ Assert.assertEquals(1, contentToDelete.size());
+ Assert.assertEquals(id, contentToDelete.get(0).getId());
+ }
+
+ /**
+ * Create a DownloadContent object with arbitrary data.
+ */
+ private DownloadContent createTestContent(String id) {
+ return new DownloadContentBuilder()
+ .setId(id)
+ .setLocation("/somewhere/something")
+ .setFilename("some.file")
+ .setChecksum("Some-checksum")
+ .setDownloadChecksum("Some-download-checksum")
+ .setLastModified(4223)
+ .setType("Some-type")
+ .setKind("Some-kind")
+ .setSize(27)
+ .setState(DownloadContent.STATE_SCHEDULED)
+ .build();
+ }
+
+ /**
+ * Create a Kinto response from a JSON file.
+ */
+ private JSONArray fromFile(String fileName) throws IOException, JSONException {
+ URL url = getClass().getResource("/" + fileName);
+ if (url == null) {
+ throw new FileNotFoundException(fileName);
+ }
+
+ InputStream inputStream = null;
+ ByteArrayOutputStream outputStream = null;
+
+ try {
+ inputStream = new BufferedInputStream(new FileInputStream(url.getPath()));
+ outputStream = new ByteArrayOutputStream();
+
+ IOUtils.copy(inputStream, outputStream);
+
+ JSONObject object = new JSONObject(outputStream.toString());
+
+ return object.getJSONArray("data");
+ } finally {
+ IOUtils.safeStreamClose(inputStream);
+ IOUtils.safeStreamClose(outputStream);
+ }
+ }
+
+ private static class MockedContentCatalog extends DownloadContentCatalog {
+ public MockedContentCatalog(DownloadContent content) {
+ super(mock(AtomicFile.class));
+
+ ArrayMap<String, DownloadContent> map = new ArrayMap<>();
+ map.put(content.getId(), content);
+
+ onCatalogLoaded(map);
+ }
+ }
+
+ private DownloadContentCatalog mockCatalog() {
+ return mock(DownloadContentCatalog.class);
+ }
+
+ private DownloadContentCatalog anyCatalog() {
+ return any(DownloadContentCatalog.class);
+ }
+
+ private JSONObject anyJSONObject() {
+ return any(JSONObject.class);
+ }
+
+ private DownloadContent anyContent() {
+ return any(DownloadContent.class);
+ }
+
+ private Context anyContext() {
+ return any(Context.class);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestVerifyAction.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestVerifyAction.java
new file mode 100644
index 000000000..6a347376e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/TestVerifyAction.java
@@ -0,0 +1,123 @@
+/* -*- 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.dlc;
+
+import android.content.Context;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.File;
+import java.util.Collections;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * VerifyAction: Validate downloaded content. Does it still exist and does it have the correct checksum?
+ */
+@RunWith(TestRunner.class)
+public class TestVerifyAction {
+ /**
+ * Scenario: Downloaded file does not exist anymore.
+ *
+ * Verify that:
+ * * Content is re-scheduled for download.
+ */
+ @Test
+ public void testReschedulingIfFileDoesNotExist() throws Exception {
+ DownloadContent content = new DownloadContentBuilder().build();
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
+
+ File file = mock(File.class);
+ when(file.exists()).thenReturn(false);
+
+ VerifyAction action = spy(new VerifyAction());
+ doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(catalog).scheduleDownload(content);
+ }
+
+ /**
+ * Scenario: Content has been scheduled for download.
+ *
+ * Verify that:
+ * * Download action is started
+ */
+ @Test
+ public void testStartingDownloadsAfterScheduling() {
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ when(catalog.hasScheduledDownloads()).thenReturn(true);
+
+ VerifyAction action = spy(new VerifyAction());
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(action).startDownloads(any(Context.class));
+ }
+
+ /**
+ * Scenario: Checksum of existing file does not match expectation.
+ *
+ * Verify that:
+ * * Content is re-scheduled for download.
+ */
+ @Test
+ public void testReschedulingIfVerificationFailed() throws Exception {
+ DownloadContent content = new DownloadContentBuilder().build();
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
+
+ File file = mock(File.class);
+ when(file.exists()).thenReturn(true);
+
+ VerifyAction action = spy(new VerifyAction());
+ doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+ doReturn(false).when(action).verify(eq(file), anyString());
+
+ action.perform(RuntimeEnvironment.application, catalog);
+
+ verify(catalog).scheduleDownload(content);
+ }
+
+ /**
+ * Scenario: Downloaded file exists and has the correct checksum.
+ *
+ * Verify that:
+ * * No download is scheduled
+ * * Download action is not started
+ */
+ @Test
+ public void testSuccessfulVerification() throws Exception {
+ DownloadContent content = new DownloadContentBuilder().build();
+ DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
+ when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
+
+ File file = mock(File.class);
+ when(file.exists()).thenReturn(true);
+
+ VerifyAction action = spy(new VerifyAction());
+ doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
+ doReturn(true).when(action).verify(eq(file), anyString());
+
+ verify(catalog, never()).scheduleDownload(content);
+ verify(action, never()).startDownloads(RuntimeEnvironment.application);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentBuilder.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentBuilder.java
new file mode 100644
index 000000000..147b5da5b
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentBuilder.java
@@ -0,0 +1,69 @@
+/* -*- 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.dlc.catalog;
+
+import org.json.JSONException;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+@RunWith(TestRunner.class)
+public class TestDownloadContentBuilder {
+ /**
+ * Verify that the values passed to the builder are all set on the DownloadContent object.
+ */
+ @Test
+ public void testBuilder() {
+ DownloadContent content = createTestContent();
+
+ Assert.assertEquals("Some-ID", content.getId());
+ Assert.assertEquals("/somewhere/something", content.getLocation());
+ Assert.assertEquals("some.file", content.getFilename());
+ Assert.assertEquals("Some-checksum", content.getChecksum());
+ Assert.assertEquals("Some-download-checksum", content.getDownloadChecksum());
+ Assert.assertEquals(4223, content.getLastModified());
+ Assert.assertEquals("Some-type", content.getType());
+ Assert.assertEquals("Some-kind", content.getKind());
+ Assert.assertEquals(27, content.getSize());
+ Assert.assertEquals(DownloadContent.STATE_SCHEDULED, content.getState());
+ }
+
+ /**
+ * Verify that a DownloadContent object exported to JSON and re-imported from JSON does not change.
+ */
+ public void testJSONSerializationAndDeserialization() throws JSONException {
+ DownloadContent content = DownloadContentBuilder.fromJSON(DownloadContentBuilder.toJSON(createTestContent()));
+
+ Assert.assertEquals("Some-ID", content.getId());
+ Assert.assertEquals("/somewhere/something", content.getLocation());
+ Assert.assertEquals("some.file", content.getFilename());
+ Assert.assertEquals("Some-checksum", content.getChecksum());
+ Assert.assertEquals("Some-download-checksum", content.getDownloadChecksum());
+ Assert.assertEquals(4223, content.getLastModified());
+ Assert.assertEquals("Some-type", content.getType());
+ Assert.assertEquals("Some-kind", content.getKind());
+ Assert.assertEquals(27, content.getSize());
+ Assert.assertEquals(DownloadContent.STATE_SCHEDULED, content.getState());
+ }
+
+ /**
+ * Create a DownloadContent object with arbitrary data.
+ */
+ private DownloadContent createTestContent() {
+ return new DownloadContentBuilder()
+ .setId("Some-ID")
+ .setLocation("/somewhere/something")
+ .setFilename("some.file")
+ .setChecksum("Some-checksum")
+ .setDownloadChecksum("Some-download-checksum")
+ .setLastModified(4223)
+ .setType("Some-type")
+ .setKind("Some-kind")
+ .setSize(27)
+ .setState(DownloadContent.STATE_SCHEDULED)
+ .build();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentCatalog.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentCatalog.java
new file mode 100644
index 000000000..5b5912cdd
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentCatalog.java
@@ -0,0 +1,262 @@
+/* -*- 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.dlc.catalog;
+
+import android.support.v4.util.ArrayMap;
+import android.support.v4.util.AtomicFile;
+
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+@RunWith(TestRunner.class)
+public class TestDownloadContentCatalog {
+ /**
+ * Scenario: Create a new, fresh catalog.
+ *
+ * Verify that:
+ * * Catalog has not changed
+ * * Unchanged catalog will not be saved to disk
+ */
+ @Test
+ public void testUntouchedCatalogHasNotChangedAndWillNotBePersisted() throws Exception {
+ AtomicFile file = mock(AtomicFile.class);
+ doReturn("{content:[]}".getBytes("UTF-8")).when(file).readFully();
+
+ DownloadContentCatalog catalog = spy(new DownloadContentCatalog(file));
+ catalog.loadFromDisk();
+
+ Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
+
+ catalog.writeToDisk();
+
+ Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
+
+ verify(file, never()).startWrite();
+ }
+
+ /**
+ * Scenario: Create a new, fresh catalog.
+ *
+ * Verify that:
+ * * Catalog is bootstrapped with items.
+ */
+ @Test
+ public void testCatalogIsBootstrappedIfFileDoesNotExist() throws Exception {
+ // The catalog is only bootstrapped if fonts are excluded from the build. If this is a build
+ // with fonts included then ignore this test.
+ Assume.assumeTrue("Fonts are excluded from build", AppConstants.MOZ_ANDROID_EXCLUDE_FONTS);
+
+ AtomicFile file = mock(AtomicFile.class);
+ doThrow(FileNotFoundException.class).when(file).readFully();
+
+ DownloadContentCatalog catalog = spy(new DownloadContentCatalog(file));
+ catalog.loadFromDisk();
+
+ Assert.assertTrue("Catalog is not empty", catalog.getContentToStudy().size() > 0);
+ }
+
+ /**
+ * Scenario: Schedule downloading an item from the catalog.
+ *
+ * Verify that:
+ * * Catalog has changed
+ */
+ @Test
+ public void testCatalogHasChangedWhenDownloadIsScheduled() throws Exception {
+ DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
+ DownloadContent content = new DownloadContentBuilder().build();
+ catalog.onCatalogLoaded(createMapOfContent(content));
+
+ Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
+
+ catalog.scheduleDownload(content);
+
+ Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
+ }
+
+ /**
+ * Scenario: Mark an item in the catalog as downloaded.
+ *
+ * Verify that:
+ * * Catalog has changed
+ */
+ @Test
+ public void testCatalogHasChangedWhenContentIsDownloaded() throws Exception {
+ DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
+ DownloadContent content = new DownloadContentBuilder().build();
+ catalog.onCatalogLoaded(createMapOfContent(content));
+
+ Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
+
+ catalog.markAsDownloaded(content);
+
+ Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
+ }
+
+ /**
+ * Scenario: Mark an item in the catalog as permanently failed.
+ *
+ * Verify that:
+ * * Catalog has changed
+ */
+ @Test
+ public void testCatalogHasChangedIfDownloadHasFailedPermanently() throws Exception {
+ DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
+ DownloadContent content = new DownloadContentBuilder().build();
+ catalog.onCatalogLoaded(createMapOfContent(content));
+
+ Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
+
+ catalog.markAsPermanentlyFailed(content);
+
+ Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
+ }
+
+ /**
+ * Scenario: A changed catalog is written to disk.
+ *
+ * Verify that:
+ * * Before write: Catalog has changed
+ * * After write: Catalog has not changed.
+ */
+ @Test
+ public void testCatalogHasNotChangedAfterWritingToDisk() throws Exception {
+ AtomicFile file = mock(AtomicFile.class);
+ doReturn(mock(FileOutputStream.class)).when(file).startWrite();
+
+ DownloadContentCatalog catalog = spy(new DownloadContentCatalog(file));
+ DownloadContent content = new DownloadContentBuilder().build();
+ catalog.onCatalogLoaded(createMapOfContent(content));
+
+ catalog.scheduleDownload(content);
+
+ Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
+
+ catalog.writeToDisk();
+
+ Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
+ }
+
+ /**
+ * Scenario: A catalog with multiple items in different states.
+ *
+ * Verify that:
+ * * getContentWithoutState(), getDownloadedContent() and getScheduledDownloads() returns
+ * the correct items depenending on their state.
+ */
+ @Test
+ public void testContentClassification() {
+ DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
+
+ DownloadContent content1 = new DownloadContentBuilder().setId("A").setState(DownloadContent.STATE_NONE).build();
+ DownloadContent content2 = new DownloadContentBuilder().setId("B").setState(DownloadContent.STATE_NONE).build();
+ DownloadContent content3 = new DownloadContentBuilder().setId("C").setState(DownloadContent.STATE_SCHEDULED).build();
+ DownloadContent content4 = new DownloadContentBuilder().setId("D").setState(DownloadContent.STATE_SCHEDULED).build();
+ DownloadContent content5 = new DownloadContentBuilder().setId("E").setState(DownloadContent.STATE_SCHEDULED).build();
+ DownloadContent content6 = new DownloadContentBuilder().setId("F").setState(DownloadContent.STATE_DOWNLOADED).build();
+ DownloadContent content7 = new DownloadContentBuilder().setId("G").setState(DownloadContent.STATE_FAILED).build();
+ DownloadContent content8 = new DownloadContentBuilder().setId("H").setState(DownloadContent.STATE_UPDATED).build();
+ DownloadContent content9 = new DownloadContentBuilder().setId("I").setState(DownloadContent.STATE_DELETED).build();
+ DownloadContent content10 = new DownloadContentBuilder().setId("J").setState(DownloadContent.STATE_DELETED).build();
+
+ catalog.onCatalogLoaded(createMapOfContent(content1, content2, content3, content4, content5, content6,
+ content7, content8, content9, content10));
+
+ Assert.assertTrue(catalog.hasScheduledDownloads());
+
+ Assert.assertEquals(3, catalog.getContentToStudy().size());
+ Assert.assertEquals(1, catalog.getDownloadedContent().size());
+ Assert.assertEquals(3, catalog.getScheduledDownloads().size());
+ Assert.assertEquals(2, catalog.getContentToDelete().size());
+
+ Assert.assertTrue(catalog.getContentToStudy().contains(content1));
+ Assert.assertTrue(catalog.getContentToStudy().contains(content2));
+ Assert.assertTrue(catalog.getContentToStudy().contains(content8));
+
+ Assert.assertTrue(catalog.getDownloadedContent().contains(content6));
+
+ Assert.assertTrue(catalog.getScheduledDownloads().contains(content3));
+ Assert.assertTrue(catalog.getScheduledDownloads().contains(content4));
+ Assert.assertTrue(catalog.getScheduledDownloads().contains(content5));
+
+ Assert.assertTrue(catalog.getContentToDelete().contains(content9));
+ Assert.assertTrue(catalog.getContentToDelete().contains(content10));
+ }
+
+ /**
+ * Scenario: Calling rememberFailure() on a catalog with varying values
+ */
+ @Test
+ public void testRememberingFailures() {
+ DownloadContentCatalog catalog = new DownloadContentCatalog(mock(AtomicFile.class));
+ Assert.assertFalse(catalog.hasCatalogChanged());
+
+ DownloadContent content = new DownloadContentBuilder().build();
+ Assert.assertEquals(0, content.getFailures());
+
+ catalog.rememberFailure(content, 42);
+ Assert.assertEquals(1, content.getFailures());
+ Assert.assertTrue(catalog.hasCatalogChanged());
+
+ catalog.rememberFailure(content, 42);
+ Assert.assertEquals(2, content.getFailures());
+
+ // Failure counter is reset if different failure has been reported
+ catalog.rememberFailure(content, 23);
+ Assert.assertEquals(1, content.getFailures());
+
+ // Failure counter is reset after successful download
+ catalog.markAsDownloaded(content);
+ Assert.assertEquals(0, content.getFailures());
+ }
+
+ /**
+ * Scenario: Content has failed multiple times with the same failure type.
+ *
+ * Verify that:
+ * * Content is marked as permanently failed
+ */
+ @Test
+ public void testContentWillBeMarkedAsPermanentlyFailedAfterMultipleFailures() {
+ DownloadContentCatalog catalog = new DownloadContentCatalog(mock(AtomicFile.class));
+
+ DownloadContent content = new DownloadContentBuilder().build();
+ Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
+
+ for (int i = 0; i < 10; i++) {
+ catalog.rememberFailure(content, 42);
+
+ Assert.assertEquals(i + 1, content.getFailures());
+ Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
+ }
+
+ catalog.rememberFailure(content, 42);
+ Assert.assertEquals(10, content.getFailures());
+ Assert.assertEquals(DownloadContent.STATE_FAILED, content.getState());
+ }
+
+ private ArrayMap<String, DownloadContent> createMapOfContent(DownloadContent... content) {
+ ArrayMap<String, DownloadContent> map = new ArrayMap<>();
+ for (DownloadContent currentContent : content) {
+ map.put(currentContent.getId(), currentContent);
+ }
+ return map;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteBlogger.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteBlogger.java
new file mode 100644
index 000000000..628b572ce
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteBlogger.java
@@ -0,0 +1,74 @@
+/* -*- 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.feeds.knownsites;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.helpers.AssertUtil;
+
+@RunWith(TestRunner.class)
+public class TestKnownSiteBlogger {
+ /**
+ * Test that the search string is a substring of some known URLs.
+ */
+ @Test
+ public void testURLSearchString() {
+ final KnownSite blogger = new KnownSiteBlogger();
+ final String searchString = blogger.getURLSearchString();
+
+ AssertUtil.assertContains(
+ "http://mykzilla.blogspot.com/",
+ searchString);
+
+ AssertUtil.assertContains(
+ "http://example.blogspot.com",
+ searchString);
+
+ AssertUtil.assertContains(
+ "https://mykzilla.blogspot.com/2015/06/introducing-pluotsorbet.html",
+ searchString);
+
+ AssertUtil.assertContains(
+ "http://android-developers.blogspot.com/2016/02/android-support-library-232.html",
+ searchString);
+
+ AssertUtil.assertContainsNot(
+ "http://www.mozilla.org",
+ searchString);
+ }
+
+ /**
+ * Test that we get a feed URL for valid Blogger URLs.
+ */
+ @Test
+ public void testGettingFeedFromURL() {
+ final KnownSite blogger = new KnownSiteBlogger();
+
+ Assert.assertEquals(
+ "https://mykzilla.blogspot.com/feeds/posts/default",
+ blogger.getFeedFromURL("http://mykzilla.blogspot.com/"));
+
+ Assert.assertEquals(
+ "https://example.blogspot.com/feeds/posts/default",
+ blogger.getFeedFromURL("http://example.blogspot.com"));
+
+ Assert.assertEquals(
+ "https://mykzilla.blogspot.com/feeds/posts/default",
+ blogger.getFeedFromURL("https://mykzilla.blogspot.com/2015/06/introducing-pluotsorbet.html"));
+
+ Assert.assertEquals(
+ "https://android-developers.blogspot.com/feeds/posts/default",
+ blogger.getFeedFromURL("http://android-developers.blogspot.com/2016/02/android-support-library-232.html"));
+
+ Assert.assertEquals(
+ "https://example.blogspot.com/feeds/posts/default",
+ blogger.getFeedFromURL("http://example.blogspot.com/2016/03/i-moved-to-example.blogspot.com"));
+
+ Assert.assertNull(blogger.getFeedFromURL("http://www.mozilla.org"));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteMedium.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteMedium.java
new file mode 100644
index 000000000..77f05e0d0
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteMedium.java
@@ -0,0 +1,66 @@
+/* -*- 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.feeds.knownsites;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.helpers.AssertUtil;
+
+@RunWith(TestRunner.class)
+public class TestKnownSiteMedium {
+ /**
+ * Test that the search string is a substring of some known URLs.
+ */
+ @Test
+ public void testURLSearchString() {
+ final KnownSite medium = new KnownSiteMedium();
+ final String searchString = medium.getURLSearchString();
+
+ AssertUtil.assertContains(
+ "https://medium.com/@Antlam/",
+ searchString);
+
+ AssertUtil.assertContains(
+ "https://medium.com/google-developers",
+ searchString);
+
+ AssertUtil.assertContains(
+ "http://medium.com/@brandonshin/how-slackbot-forced-us-to-workout-7b4741a2de73",
+ searchString
+ );
+
+ AssertUtil.assertContainsNot(
+ "http://www.mozilla.org",
+ searchString);
+ }
+
+ /**
+ * Test that we get a feed URL for valid Medium URLs.
+ */
+ @Test
+ public void testGettingFeedFromURL() {
+ final KnownSite medium = new KnownSiteMedium();
+
+ Assert.assertEquals(
+ "https://medium.com/feed/@Antlam",
+ medium.getFeedFromURL("https://medium.com/@Antlam/")
+ );
+
+ Assert.assertEquals(
+ "https://medium.com/feed/google-developers",
+ medium.getFeedFromURL("https://medium.com/google-developers")
+ );
+
+ Assert.assertEquals(
+ "https://medium.com/feed/@brandonshin",
+ medium.getFeedFromURL("http://medium.com/@brandonshin/how-slackbot-forced-us-to-workout-7b4741a2de73")
+ );
+
+ Assert.assertNull(medium.getFeedFromURL("http://www.mozilla.org"));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteTumblr.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteTumblr.java
new file mode 100644
index 000000000..f83272f82
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteTumblr.java
@@ -0,0 +1,62 @@
+/* -*- 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.feeds.knownsites;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.helpers.AssertUtil;
+
+@RunWith(TestRunner.class)
+public class TestKnownSiteTumblr {
+ /**
+ * Test that the search string is a substring of some known URLs.
+ */
+ @Test
+ public void testURLSearchString() {
+ final KnownSite tumblr = new KnownSiteTumblr();
+ final String searchString = tumblr.getURLSearchString();
+
+ AssertUtil.assertContains(
+ "http://contentnotifications.tumblr.com/",
+ searchString);
+
+ AssertUtil.assertContains(
+ "https://contentnotifications.tumblr.com",
+ searchString);
+
+ AssertUtil.assertContains(
+ "http://contentnotifications.tumblr.com/post/142684202402/content-notification-firefox-for-android-480",
+ searchString);
+
+ AssertUtil.assertContainsNot(
+ "http://www.mozilla.org",
+ searchString);
+ }
+
+ /**
+ * Test that we get a feed URL for valid Medium URLs.
+ */
+ @Test
+ public void testGettingFeedFromURL() {
+ final KnownSite tumblr = new KnownSiteTumblr();
+
+ Assert.assertEquals(
+ "http://contentnotifications.tumblr.com/rss",
+ tumblr.getFeedFromURL("http://contentnotifications.tumblr.com/")
+ );
+
+ Assert.assertEquals(
+ "http://staff.tumblr.com/rss",
+ tumblr.getFeedFromURL("https://staff.tumblr.com/post/141928246566/replies-are-back-and-the-sun-is-shining-on-the")
+ );
+
+ Assert.assertNull(tumblr.getFeedFromURL("https://www.tumblr.com"));
+
+ Assert.assertNull(tumblr.getFeedFromURL("http://www.mozilla.org"));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/parser/TestSimpleFeedParser.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/parser/TestSimpleFeedParser.java
new file mode 100644
index 000000000..fa2fffbad
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/parser/TestSimpleFeedParser.java
@@ -0,0 +1,323 @@
+/* -*- 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.feeds.parser;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.UnsupportedEncodingException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.text.SimpleDateFormat;
+import java.util.Locale;
+
+@RunWith(TestRunner.class)
+public class TestSimpleFeedParser {
+ /**
+ * Parse and verify the RSS example from Wikipedia:
+ * https://en.wikipedia.org/wiki/RSS#Example
+ */
+ @Test
+ public void testRSSExample() throws Exception {
+ InputStream stream = openFeed("feed_rss_wikipedia.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("RSS Title", feed.getTitle());
+ Assert.assertEquals("http://www.example.com/main.html", feed.getWebsiteURL());
+ Assert.assertNull(feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("Example entry", item.getTitle());
+ Assert.assertEquals("http://www.example.com/blog/post/1", item.getURL());
+ Assert.assertEquals(1252254000000L, item.getTimestamp());
+ }
+
+ /**
+ * Parse and verify the ATOM example from Wikipedia:
+ * https://en.wikipedia.org/wiki/Atom_%28standard%29#Example_of_an_Atom_1.0_feed
+ */
+ @Test
+ public void testATOMExample() throws Exception {
+ InputStream stream = openFeed("feed_atom_wikipedia.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("Example Feed", feed.getTitle());
+ Assert.assertEquals("http://example.org/", feed.getWebsiteURL());
+ Assert.assertEquals("http://example.org/feed/", feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("Atom-Powered Robots Run Amok", item.getTitle());
+ Assert.assertEquals("http://example.org/2003/12/13/atom03.html", item.getURL());
+ Assert.assertEquals(1071340202000L, item.getTimestamp());
+ }
+
+ /**
+ * Parse and verify a snapshot of a Medium feed.
+ */
+ @Test
+ public void testMediumFeed() throws Exception {
+ InputStream stream = openFeed("feed_rss_medium.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("Anthony Lam on Medium", feed.getTitle());
+ Assert.assertEquals("https://medium.com/@antlam?source=rss-59f49b9e4b19------2", feed.getWebsiteURL());
+ Assert.assertEquals("https://medium.com/feed/@antlam", feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("UX thoughts for 2016", item.getTitle());
+ Assert.assertEquals("https://medium.com/@antlam/ux-thoughts-for-2016-1fc1d6e515e8?source=rss-59f49b9e4b19------2", item.getURL());
+ Assert.assertEquals(1452537838000L, item.getTimestamp());
+ }
+
+ /**
+ * Parse and verify a snapshot of planet.mozilla.org ATOM feed.
+ */
+ @Test
+ public void testPlanetMozillaATOMFeed() throws Exception {
+ InputStream stream = openFeed("feed_atom_planetmozilla.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("Planet Mozilla", feed.getTitle());
+ Assert.assertEquals("http://planet.mozilla.org/", feed.getWebsiteURL());
+ Assert.assertEquals("http://planet.mozilla.org/atom.xml", feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("Firefox 45.0 Beta 3 Testday, February 5th", item.getTitle());
+ Assert.assertEquals("https://quality.mozilla.org/2016/01/firefox-45-0-beta-3-testday-february-5th/", item.getURL());
+ Assert.assertEquals(1453819255000L, item.getTimestamp());
+ }
+
+ /**
+ * Parse and verify a snapshot of planet.mozilla.org RSS 2.0 feed.
+ */
+ @Test
+ public void testPlanetMozillaRSS20Feed() throws Exception {
+ InputStream stream = openFeed("feed_rss20_planetmozilla.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("Planet Mozilla", feed.getTitle());
+ Assert.assertEquals("http://planet.mozilla.org/", feed.getWebsiteURL());
+ Assert.assertEquals("http://planet.mozilla.org/rss20.xml", feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("Aaron Klotz: Announcing Mozdbgext", item.getTitle());
+ Assert.assertEquals("http://dblohm7.ca/blog/2016/01/26/announcing-mozdbgext/", item.getURL());
+ Assert.assertEquals(1453837500000L, item.getTimestamp());
+ }
+
+ /**
+ * Parse and verify a snapshot of planet.mozilla.org RSS 1.0 feed.
+ */
+ @Test
+ public void testPlanetMozillaRSS10Feed() throws Exception {
+ InputStream stream = openFeed("feed_rss10_planetmozilla.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("Planet Mozilla", feed.getTitle());
+ Assert.assertEquals("http://planet.mozilla.org/", feed.getWebsiteURL());
+ Assert.assertEquals("http://planet.mozilla.org/rss10.xml", feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("Aaron Klotz: Announcing Mozdbgext", item.getTitle());
+ Assert.assertEquals("http://dblohm7.ca/blog/2016/01/26/announcing-mozdbgext/", item.getURL());
+ Assert.assertEquals(1453837500000L, item.getTimestamp());
+ }
+
+ /**
+ * Parse an verify a snapshot of a feedburner ATOM feed.
+ */
+ @Test
+ public void testFeedburnerAtomFeed() throws Exception {
+ InputStream stream = openFeed("feed_atom_feedburner.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("Android Zeitgeist", feed.getTitle());
+ Assert.assertEquals("http://www.androidzeitgeist.com/", feed.getWebsiteURL());
+ Assert.assertEquals("http://feeds.feedburner.com/AndroidZeitgeist", feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("Support for restricted profiles in Firefox 42", item.getTitle());
+ Assert.assertEquals("http://feedproxy.google.com/~r/AndroidZeitgeist/~3/xaSicfGuwOU/support-restricted-profiles-firefox.html", item.getURL());
+ Assert.assertEquals(1442511968239L, item.getTimestamp());
+ }
+
+ /**
+ * Parse and verify a snapshot of a Tumblr RSS feed.
+ */
+ @Test
+ public void testTumblrRssFeed() throws Exception {
+ InputStream stream = openFeed("feed_rss_tumblr.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("Tumblr Staff", feed.getTitle());
+ Assert.assertEquals("http://staff.tumblr.com/", feed.getWebsiteURL());
+ Assert.assertNull(feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("hardyboyscovers: Can Nancy Drew see things through and solve...", item.getTitle());
+ Assert.assertEquals("http://staff.tumblr.com/post/138124026275", item.getURL());
+ Assert.assertEquals(1453861812000L, item.getTimestamp());
+ }
+
+ /**
+ * Parse and verify a snapshot of a Spiegel (German news magazine) RSS feed.
+ */
+ @Test
+ public void testSpiegelRssFeed() throws Exception {
+ InputStream stream = openFeed("feed_rss_spon.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("SPIEGEL ONLINE - Schlagzeilen", feed.getTitle());
+ Assert.assertEquals("http://www.spiegel.de", feed.getWebsiteURL());
+ Assert.assertNull(feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("Angebliche Vergewaltigung einer 13-Jährigen: Steinmeier kanzelt russischen Minister Lawrow ab", item.getTitle());
+ Assert.assertEquals("http://www.spiegel.de/politik/ausland/steinmeier-kanzelt-lawrow-ab-aerger-um-angebliche-vergewaltigung-a-1074292.html#ref=rss", item.getURL());
+ Assert.assertEquals(1453914976000L, item.getTimestamp());
+ }
+
+ /**
+ * Parse and verify a snapshot of a Heise (German tech news) RSS feed.
+ */
+ @Test
+ public void testHeiseRssFeed() throws Exception {
+ InputStream stream = openFeed("feed_rss_heise.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("heise online News", feed.getTitle());
+ Assert.assertEquals("http://www.heise.de/newsticker/", feed.getWebsiteURL());
+ Assert.assertNull(feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("Google: “Dramatische Verbesserungen” für Chrome in iOS", item.getTitle());
+ Assert.assertEquals("http://www.heise.de/newsticker/meldung/Google-Dramatische-Verbesserungen-fuer-Chrome-in-iOS-3085808.html?wt_mc=rss.ho.beitrag.atom", item.getURL());
+ Assert.assertEquals(1453915920000L, item.getTimestamp());
+ }
+
+ @Test
+ public void testWordpressFeed() throws Exception {
+ InputStream stream = openFeed("feed_rss_wordpress.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("justasimpletest2016", feed.getTitle());
+ Assert.assertEquals("https://justasimpletest2016.wordpress.com", feed.getWebsiteURL());
+ Assert.assertEquals("https://justasimpletest2016.wordpress.com/feed/", feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("Hello World!", item.getTitle());
+ Assert.assertEquals("https://justasimpletest2016.wordpress.com/2016/02/26/hello-world/", item.getURL());
+ Assert.assertEquals(1456524466000L, item.getTimestamp());
+ }
+
+ /**
+ * Parse and test a snapshot of mykzilla.blogspot.com
+ */
+ @Test
+ public void testBloggerFeed() throws Exception {
+ InputStream stream = openFeed("feed_atom_blogger.xml");
+
+ SimpleFeedParser parser = new SimpleFeedParser();
+ Feed feed = parser.parse(stream);
+
+ Assert.assertNotNull(feed);
+ Assert.assertEquals("mykzilla", feed.getTitle());
+ Assert.assertEquals("http://mykzilla.blogspot.com/", feed.getWebsiteURL());
+ Assert.assertEquals("http://www.blogger.com/feeds/18929277/posts/default", feed.getFeedURL());
+ Assert.assertTrue(feed.isSufficientlyComplete());
+
+ Item item = feed.getLastItem();
+
+ Assert.assertNotNull(item);
+ Assert.assertEquals("URL Has Been Changed", item.getTitle());
+ Assert.assertEquals("http://mykzilla.blogspot.com/2016/01/url-has-been-changed.html", item.getURL());
+ Assert.assertEquals(1452531451366L, item.getTimestamp());
+ }
+
+ private InputStream openFeed(String fileName) throws URISyntaxException, FileNotFoundException, UnsupportedEncodingException {
+ URL url = getClass().getResource("/" + fileName);
+ if (url == null) {
+ throw new FileNotFoundException(fileName);
+ }
+
+ return new BufferedInputStream(new FileInputStream(url.getPath()));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/TestSkewHandler.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/TestSkewHandler.java
new file mode 100644
index 000000000..2b4fe3e03
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/TestSkewHandler.java
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy 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 ch.boye.httpclientandroidlib.impl.cookie.DateUtils;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.fxa.SkewHandler;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.net.BaseResource;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestSkewHandler {
+ public TestSkewHandler() {
+ }
+
+ @Test
+ public void testSkewUpdating() throws Throwable {
+ SkewHandler h = new SkewHandler("foo.com");
+ assertEquals(0L, h.getSkewInSeconds());
+ assertEquals(0L, h.getSkewInMillis());
+
+ long server = 1390101197865L;
+ long local = server - 4500L;
+ h.updateSkewFromServerMillis(server, local);
+ assertEquals(4500L, h.getSkewInMillis());
+ assertEquals(4L, h.getSkewInSeconds());
+
+ local = server;
+ h.updateSkewFromServerMillis(server, local);
+ assertEquals(0L, h.getSkewInMillis());
+ assertEquals(0L, h.getSkewInSeconds());
+
+ local = server + 500L;
+ h.updateSkewFromServerMillis(server, local);
+ assertEquals(-500L, h.getSkewInMillis());
+ assertEquals(0L, h.getSkewInSeconds());
+
+ String date = "Sat, 18 Jan 2014 19:16:52 PST";
+ long dateInMillis = 1390101412000L; // Obviously this can differ somewhat due to precision.
+ long parsed = DateUtils.parseDate(date).getTime();
+ assertEquals(parsed, dateInMillis);
+
+ h.updateSkewFromHTTPDateString(date, dateInMillis);
+ assertEquals(0L, h.getSkewInMillis());
+ assertEquals(0L, h.getSkewInSeconds());
+
+ h.updateSkewFromHTTPDateString(date, dateInMillis + 1100L);
+ assertEquals(-1100L, h.getSkewInMillis());
+ assertEquals(Math.round(-1100L / 1000L), h.getSkewInSeconds());
+ }
+
+ @Test
+ public void testSkewSingleton() throws Exception {
+ SkewHandler h1 = SkewHandler.getSkewHandlerFromEndpointString("http://foo.com/bar");
+ SkewHandler h2 = SkewHandler.getSkewHandlerForHostname("foo.com");
+ SkewHandler h3 = SkewHandler.getSkewHandlerForResource(new BaseResource("http://foo.com/baz"));
+ assertTrue(h1 == h2);
+ assertTrue(h1 == h3);
+
+ SkewHandler.getSkewHandlerForHostname("foo.com").updateSkewFromServerMillis(1390101412000L, 1390001412000L);
+ final long actual = SkewHandler.getSkewHandlerForHostname("foo.com").getSkewInMillis();
+ assertEquals(100000000L, actual);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java
new file mode 100644
index 000000000..868e90cd2
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.fxa.login;
+
+import android.text.TextUtils;
+
+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.FxAccountClient20.RecoveryEmailStatusResponse;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.TwoKeys;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.LoginResponse;
+import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
+import org.mozilla.gecko.background.fxa.FxAccountRemoteError;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.fxa.FxAccountDevice;
+import org.mozilla.gecko.browserid.MockMyIDTokenFactory;
+import org.mozilla.gecko.browserid.RSACryptoImplementation;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+
+import java.io.UnsupportedEncodingException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import ch.boye.httpclientandroidlib.HttpStatus;
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.entity.StringEntity;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+
+public class MockFxAccountClient implements FxAccountClient {
+ protected static MockMyIDTokenFactory mockMyIdTokenFactory = new MockMyIDTokenFactory();
+
+ public final String serverURI = "http://testServer.com";
+
+ public final Map<String, User> users = new HashMap<String, User>();
+ public final Map<String, String> sessionTokens = new HashMap<String, String>();
+ public final Map<String, String> keyFetchTokens = new HashMap<String, String>();
+
+ public static class User {
+ public final String email;
+ public final byte[] quickStretchedPW;
+ public final String uid;
+ public boolean verified;
+ public final byte[] kA;
+ public final byte[] wrapkB;
+ public final Map<String, FxAccountDevice> devices;
+
+ public User(String email, byte[] quickStretchedPW) {
+ this.email = email;
+ this.quickStretchedPW = quickStretchedPW;
+ this.uid = "uid/" + this.email;
+ this.verified = false;
+ this.kA = Utils.generateRandomBytes(FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES);
+ this.wrapkB = Utils.generateRandomBytes(FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES);
+ this.devices = new HashMap<String, FxAccountDevice>();
+ }
+ }
+
+ protected LoginResponse addLogin(User user, byte[] sessionToken, byte[] keyFetchToken) {
+ // byte[] sessionToken = Utils.generateRandomBytes(8);
+ if (sessionToken != null) {
+ sessionTokens.put(Utils.byte2Hex(sessionToken), user.email);
+ }
+ // byte[] keyFetchToken = Utils.generateRandomBytes(8);
+ if (keyFetchToken != null) {
+ keyFetchTokens.put(Utils.byte2Hex(keyFetchToken), user.email);
+ }
+ return new LoginResponse(user.email, user.uid, user.verified, sessionToken, keyFetchToken);
+ }
+
+ public void addUser(String email, byte[] quickStretchedPW, boolean verified, byte[] sessionToken, byte[] keyFetchToken) {
+ User user = new User(email, quickStretchedPW);
+ users.put(email, user);
+ if (verified) {
+ verifyUser(email);
+ }
+ addLogin(user, sessionToken, keyFetchToken);
+ }
+
+ public void verifyUser(String email) {
+ users.get(email).verified = true;
+ }
+
+ public void clearAllUserTokens() throws UnsupportedEncodingException {
+ sessionTokens.clear();
+ keyFetchTokens.clear();
+ }
+
+ protected BasicHttpResponse makeHttpResponse(int statusCode, String body) {
+ BasicHttpResponse httpResponse = new BasicHttpResponse(new ProtocolVersion("HTTP", 1, 1), statusCode, body);
+ httpResponse.setEntity(new StringEntity(body, "UTF-8"));
+ return httpResponse;
+ }
+
+ protected <T> void handleFailure(RequestDelegate<T> requestDelegate, int code, int errno, String message) {
+ requestDelegate.handleFailure(new FxAccountClientRemoteException(makeHttpResponse(code, message),
+ code, errno, "Bad authorization", message, null, new ExtendedJSONObject()));
+ }
+
+ @Override
+ public void accountStatus(String uid, RequestDelegate<AccountStatusResponse> requestDelegate) {
+ boolean userFound = false;
+ for (User user : users.values()) {
+ if (user.uid.equals(uid)) {
+ userFound = true;
+ break;
+ }
+ }
+ requestDelegate.handleSuccess(new AccountStatusResponse(userFound));
+ }
+
+ @Override
+ public void recoveryEmailStatus(byte[] sessionToken, RequestDelegate<RecoveryEmailStatusResponse> requestDelegate) {
+ String email = sessionTokens.get(Utils.byte2Hex(sessionToken));
+ User user = users.get(email);
+ if (email == null || user == null) {
+ handleFailure(requestDelegate, HttpStatus.SC_UNAUTHORIZED, FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN, "invalid sessionToken");
+ return;
+ }
+ requestDelegate.handleSuccess(new RecoveryEmailStatusResponse(email, user.verified));
+ }
+
+ @Override
+ public void keys(byte[] keyFetchToken, RequestDelegate<TwoKeys> requestDelegate) {
+ String email = keyFetchTokens.get(Utils.byte2Hex(keyFetchToken));
+ User user = users.get(email);
+ if (email == null || user == null) {
+ handleFailure(requestDelegate, HttpStatus.SC_UNAUTHORIZED, FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN, "invalid keyFetchToken");
+ return;
+ }
+ if (!user.verified) {
+ handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT, "user is unverified");
+ return;
+ }
+ requestDelegate.handleSuccess(new TwoKeys(user.kA, user.wrapkB));
+ }
+
+ @Override
+ public void sign(byte[] sessionToken, ExtendedJSONObject publicKey, long certificateDurationInMilliseconds, RequestDelegate<String> requestDelegate) {
+ String email = sessionTokens.get(Utils.byte2Hex(sessionToken));
+ User user = users.get(email);
+ if (email == null || user == null) {
+ handleFailure(requestDelegate, HttpStatus.SC_UNAUTHORIZED, FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN, "invalid sessionToken");
+ return;
+ }
+ if (!user.verified) {
+ handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT, "user is unverified");
+ return;
+ }
+ try {
+ final long iat = System.currentTimeMillis();
+ final long dur = certificateDurationInMilliseconds;
+ final long exp = iat + dur;
+ String certificate = mockMyIdTokenFactory.createMockMyIDCertificate(RSACryptoImplementation.createPublicKey(publicKey), "test", iat, exp);
+ requestDelegate.handleSuccess(certificate);
+ } catch (Exception e) {
+ requestDelegate.handleError(e);
+ }
+ }
+
+ @Override
+ public void registerOrUpdateDevice(byte[] sessionToken, FxAccountDevice deviceToRegister, RequestDelegate<FxAccountDevice> requestDelegate) {
+ String email = sessionTokens.get(Utils.byte2Hex(sessionToken));
+ User user = users.get(email);
+ if (email == null || user == null) {
+ handleFailure(requestDelegate, HttpStatus.SC_UNAUTHORIZED, FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN, "invalid sessionToken");
+ return;
+ }
+ if (!user.verified) {
+ handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT, "user is unverified");
+ return;
+ }
+ try {
+ String deviceId = deviceToRegister.id;
+ if (TextUtils.isEmpty(deviceId)) { // Create
+ deviceId = UUID.randomUUID().toString();
+ FxAccountDevice device = new FxAccountDevice(deviceToRegister.name, deviceId, deviceToRegister.type, null, null, null, null);
+ requestDelegate.handleSuccess(device);
+ } else { // Update
+ FxAccountDevice existingDevice = user.devices.get(deviceId);
+ if (existingDevice != null) {
+ String deviceName = existingDevice.name;
+ if (!TextUtils.isEmpty(deviceToRegister.name)) {
+ deviceName = deviceToRegister.name;
+ } // We could also update the other fields..
+ FxAccountDevice device = new FxAccountDevice(deviceName, existingDevice.id, existingDevice.type,
+ existingDevice.isCurrentDevice, existingDevice.pushCallback, existingDevice.pushPublicKey,existingDevice.pushAuthKey);
+ requestDelegate.handleSuccess(device);
+ } else { // Device unknown
+ handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.UNKNOWN_DEVICE, "device is unknown");
+ return;
+ }
+ }
+ } catch (Exception e) {
+ requestDelegate.handleError(e);
+ }
+ }
+
+ @Override
+ public void deviceList(byte[] sessionToken, RequestDelegate<FxAccountDevice[]> requestDelegate) {
+ String email = sessionTokens.get(Utils.byte2Hex(sessionToken));
+ User user = users.get(email);
+ if (email == null || user == null) {
+ handleFailure(requestDelegate, HttpStatus.SC_UNAUTHORIZED, FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN, "invalid sessionToken");
+ return;
+ }
+ if (!user.verified) {
+ handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT, "user is unverified");
+ return;
+ }
+ Collection<FxAccountDevice> devices = user.devices.values();
+ FxAccountDevice[] devicesArray = devices.toArray(new FxAccountDevice[devices.size()]);
+ requestDelegate.handleSuccess(devicesArray);
+ }
+
+ @Override
+ public void notifyDevices(byte[] sessionToken, List<String> deviceIds, ExtendedJSONObject payload, Long TTL, RequestDelegate<ExtendedJSONObject> requestDelegate) {
+ requestDelegate.handleSuccess(new ExtendedJSONObject());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestFxAccountLoginStateMachine.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestFxAccountLoginStateMachine.java
new file mode 100644
index 000000000..1496f6d79
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestFxAccountLoginStateMachine.java
@@ -0,0 +1,205 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.fxa.login;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.RSACryptoImplementation;
+import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate;
+import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.sync.Utils;
+
+import java.security.NoSuchAlgorithmException;
+import java.util.LinkedList;
+
+@RunWith(TestRunner.class)
+public class TestFxAccountLoginStateMachine {
+ // private static final String TEST_AUDIENCE = "http://testAudience.com";
+ private static final String TEST_EMAIL = "test@test.com";
+ private static byte[] TEST_EMAIL_UTF8;
+ private static final String TEST_PASSWORD = "testtest";
+ private static byte[] TEST_PASSWORD_UTF8;
+ private static byte[] TEST_QUICK_STRETCHED_PW;
+ private static byte[] TEST_UNWRAPKB;
+ private static final byte[] TEST_SESSION_TOKEN = Utils.generateRandomBytes(32);
+ private static final byte[] TEST_KEY_FETCH_TOKEN = Utils.generateRandomBytes(32);
+
+ protected MockFxAccountClient client;
+ protected FxAccountLoginStateMachine sm;
+
+ @Before
+ public void setUp() throws Exception {
+ if (TEST_EMAIL_UTF8 == null) {
+ TEST_EMAIL_UTF8 = TEST_EMAIL.getBytes("UTF-8");
+ }
+ if (TEST_PASSWORD_UTF8 == null) {
+ TEST_PASSWORD_UTF8 = TEST_PASSWORD.getBytes("UTF-8");
+ }
+ if (TEST_QUICK_STRETCHED_PW == null) {
+ TEST_QUICK_STRETCHED_PW = FxAccountUtils.generateQuickStretchedPW(TEST_EMAIL_UTF8, TEST_PASSWORD_UTF8);
+ }
+ if (TEST_UNWRAPKB == null) {
+ TEST_UNWRAPKB = FxAccountUtils.generateUnwrapBKey(TEST_QUICK_STRETCHED_PW);
+ }
+ client = new MockFxAccountClient();
+ sm = new FxAccountLoginStateMachine();
+ }
+
+ protected static class Trace {
+ public final LinkedList<State> states;
+ public final LinkedList<Transition> transitions;
+
+ public Trace(LinkedList<State> states, LinkedList<Transition> transitions) {
+ this.states = states;
+ this.transitions = transitions;
+ }
+
+ public void assertEquals(String string) {
+ Assert.assertArrayEquals(string.split(", "), toString().split(", "));
+ }
+
+ @Override
+ public String toString() {
+ final LinkedList<State> states = new LinkedList<State>(this.states);
+ final LinkedList<Transition> transitions = new LinkedList<Transition>(this.transitions);
+ LinkedList<String> names = new LinkedList<String>();
+ State state;
+ while ((state = states.pollFirst()) != null) {
+ names.add(state.getStateLabel().name());
+ Transition transition = transitions.pollFirst();
+ if (transition != null) {
+ names.add(">" + transition.toString());
+ }
+ }
+ return names.toString();
+ }
+
+ public String stateString() {
+ LinkedList<String> names = new LinkedList<String>();
+ for (State state : states) {
+ names.add(state.getStateLabel().name());
+ }
+ return names.toString();
+ }
+
+ public String transitionString() {
+ LinkedList<String> names = new LinkedList<String>();
+ for (Transition transition : transitions) {
+ names.add(transition.toString());
+ }
+ return names.toString();
+ }
+ }
+
+ protected Trace trace(final State initialState, final StateLabel desiredState) {
+ final LinkedList<Transition> transitions = new LinkedList<Transition>();
+ final LinkedList<State> states = new LinkedList<State>();
+ states.add(initialState);
+
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ sm.advance(initialState, desiredState, new LoginStateMachineDelegate() {
+ @Override
+ public void handleTransition(Transition transition, State state) {
+ transitions.add(transition);
+ states.add(state);
+ }
+
+ @Override
+ public void handleFinal(State state) {
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public FxAccountClient getClient() {
+ return client;
+ }
+
+ @Override
+ public long getCertificateDurationInMilliseconds() {
+ return 30 * 1000;
+ }
+
+ @Override
+ public long getAssertionDurationInMilliseconds() {
+ return 10 * 1000;
+ }
+
+ @Override
+ public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException {
+ return RSACryptoImplementation.generateKeyPair(512);
+ }
+ });
+ }
+ });
+
+ return new Trace(states, transitions);
+ }
+
+ @Test
+ public void testEnagedUnverified() throws Exception {
+ client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, false, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+ Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+ trace.assertEquals("[Engaged, >AccountNeedsVerification, Engaged]");
+ }
+
+ @Test
+ public void testEngagedTransitionToAccountVerified() throws Exception {
+ client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+ Trace trace = trace(new Engaged(TEST_EMAIL, "uid", false, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+ trace.assertEquals("[Engaged, >AccountVerified, Cohabiting, >LogMessage('sign succeeded'), Married]");
+ }
+
+ @Test
+ public void testEngagedVerified() throws Exception {
+ client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+ Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+ trace.assertEquals("[Engaged, >LogMessage('keys succeeded'), Cohabiting, >LogMessage('sign succeeded'), Married]");
+ }
+
+ @Test
+ public void testPartial() throws Exception {
+ client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+ // What if we stop at Cohabiting?
+ Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Cohabiting);
+ trace.assertEquals("[Engaged, >LogMessage('keys succeeded'), Cohabiting]");
+ }
+
+ @Test
+ public void testBadSessionToken() throws Exception {
+ client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+ client.sessionTokens.clear();
+ Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+ trace.assertEquals("[Engaged, >LogMessage('keys succeeded'), Cohabiting, >Log(<FxAccountClientRemoteException 401 [110]: invalid sessionToken>), Separated, >PasswordRequired, Separated]");
+ }
+
+ @Test
+ public void testBadKeyFetchToken() throws Exception {
+ client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+ client.keyFetchTokens.clear();
+ Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+ trace.assertEquals("[Engaged, >Log(<FxAccountClientRemoteException 401 [110]: invalid keyFetchToken>), Separated, >PasswordRequired, Separated]");
+ }
+
+ @Test
+ public void testMarried() throws Exception {
+ client.addUser(TEST_EMAIL, TEST_QUICK_STRETCHED_PW, true, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN);
+ Trace trace = trace(new Engaged(TEST_EMAIL, "uid", true, TEST_UNWRAPKB, TEST_SESSION_TOKEN, TEST_KEY_FETCH_TOKEN), StateLabel.Married);
+ trace.assertEquals("[Engaged, >LogMessage('keys succeeded'), Cohabiting, >LogMessage('sign succeeded'), Married]");
+ // What if we're already in the desired state?
+ State married = trace.states.getLast();
+ Assert.assertEquals(StateLabel.Married, married.getStateLabel());
+ trace = trace(married, StateLabel.Married);
+ trace.assertEquals("[Married]");
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestStateFactory.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestStateFactory.java
new file mode 100644
index 000000000..80d7d7f9f
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestStateFactory.java
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.fxa.login;
+
+import junit.framework.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.browserid.BrowserIDKeyPair;
+import org.mozilla.gecko.browserid.DSACryptoImplementation;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.Utils;
+
+@RunWith(TestRunner.class)
+public class TestStateFactory {
+ @Test
+ public void testGetStateV3() throws Exception {
+ MigratedFromSync11 migrated = new MigratedFromSync11("email", "uid", true, "password");
+
+ // For the current version, we expect to read back what we wrote.
+ ExtendedJSONObject o;
+ State state;
+
+ o = migrated.toJSONObject();
+ Assert.assertEquals(3, o.getLong("version").intValue());
+ state = StateFactory.fromJSONObject(migrated.stateLabel, o);
+ Assert.assertEquals(StateLabel.MigratedFromSync11, state.stateLabel);
+ Assert.assertEquals(o, state.toJSONObject());
+
+ // Null passwords are OK.
+ MigratedFromSync11 migratedNullPassword = new MigratedFromSync11("email", "uid", true, null);
+
+ o = migratedNullPassword.toJSONObject();
+ Assert.assertEquals(3, o.getLong("version").intValue());
+ state = StateFactory.fromJSONObject(migratedNullPassword.stateLabel, o);
+ Assert.assertEquals(StateLabel.MigratedFromSync11, state.stateLabel);
+ Assert.assertEquals(o, state.toJSONObject());
+ }
+
+ @Test
+ public void testGetStateV2() throws Exception {
+ byte[] sessionToken = Utils.generateRandomBytes(32);
+ byte[] kA = Utils.generateRandomBytes(32);
+ byte[] kB = Utils.generateRandomBytes(32);
+ BrowserIDKeyPair keyPair = DSACryptoImplementation.generateKeyPair(512);
+ Cohabiting cohabiting = new Cohabiting("email", "uid", sessionToken, kA, kB, keyPair);
+ String certificate = "certificate";
+ Married married = new Married("email", "uid", sessionToken, kA, kB, keyPair, certificate);
+
+ // For the current version, we expect to read back what we wrote.
+ ExtendedJSONObject o;
+ State state;
+
+ o = married.toJSONObject();
+ Assert.assertEquals(3, o.getLong("version").intValue());
+ state = StateFactory.fromJSONObject(married.stateLabel, o);
+ Assert.assertEquals(StateLabel.Married, state.stateLabel);
+ Assert.assertEquals(o, state.toJSONObject());
+
+ o = cohabiting.toJSONObject();
+ Assert.assertEquals(3, o.getLong("version").intValue());
+ state = StateFactory.fromJSONObject(cohabiting.stateLabel, o);
+ Assert.assertEquals(StateLabel.Cohabiting, state.stateLabel);
+ Assert.assertEquals(o, state.toJSONObject());
+ }
+
+ @Test
+ public void testGetStateV1() throws Exception {
+ // We can't rely on generating correct V1 objects (since the generation code
+ // may change); so we hard code a few test examples here. These examples
+ // have RSA key pairs; when they're parsed, we return DSA key pairs.
+ ExtendedJSONObject o = new ExtendedJSONObject("{\"uid\":\"uid\",\"sessionToken\":\"4e2830da6ce466ddb401fbca25b96a621209eea83851254800f84cc4069ef011\",\"certificate\":\"certificate\",\"keyPair\":{\"publicKey\":{\"e\":\"65537\",\"n\":\"7598360104379019497828904063491254083855849024432238665262988260947462372141971045236693389494635158997975098558915846889960089362159921622822266839560631\",\"algorithm\":\"RS\"},\"privateKey\":{\"d\":\"6807533330618101360064115400338014782301295929300445938471117364691566605775022173055292460962170873583673516346599808612503093914221141089102289381448225\",\"n\":\"7598360104379019497828904063491254083855849024432238665262988260947462372141971045236693389494635158997975098558915846889960089362159921622822266839560631\",\"algorithm\":\"RS\"}},\"email\":\"email\",\"verified\":true,\"kB\":\"0b048f285c19067f200da7bfbe734ed213cefcd8f543f0fdd4a8ccab48cbbc89\",\"kA\":\"59a9edf2d41de8b24e69df9133bc88e96913baa75421882f4c55d842d18fc8a1\",\"version\":1}");
+ // A Married state is regressed to a Cohabited state.
+ Cohabiting state = (Cohabiting) StateFactory.fromJSONObject(StateLabel.Married, o);
+
+ Assert.assertEquals(StateLabel.Cohabiting, state.stateLabel);
+ Assert.assertEquals("uid", state.uid);
+ Assert.assertEquals("4e2830da6ce466ddb401fbca25b96a621209eea83851254800f84cc4069ef011", Utils.byte2Hex(state.sessionToken));
+ Assert.assertEquals("DS128", state.keyPair.getPrivate().getAlgorithm());
+
+ o = new ExtendedJSONObject("{\"uid\":\"uid\",\"sessionToken\":\"4e2830da6ce466ddb401fbca25b96a621209eea83851254800f84cc4069ef011\",\"keyPair\":{\"publicKey\":{\"e\":\"65537\",\"n\":\"7598360104379019497828904063491254083855849024432238665262988260947462372141971045236693389494635158997975098558915846889960089362159921622822266839560631\",\"algorithm\":\"RS\"},\"privateKey\":{\"d\":\"6807533330618101360064115400338014782301295929300445938471117364691566605775022173055292460962170873583673516346599808612503093914221141089102289381448225\",\"n\":\"7598360104379019497828904063491254083855849024432238665262988260947462372141971045236693389494635158997975098558915846889960089362159921622822266839560631\",\"algorithm\":\"RS\"}},\"email\":\"email\",\"verified\":true,\"kB\":\"0b048f285c19067f200da7bfbe734ed213cefcd8f543f0fdd4a8ccab48cbbc89\",\"kA\":\"59a9edf2d41de8b24e69df9133bc88e96913baa75421882f4c55d842d18fc8a1\",\"version\":1}");
+ state = (Cohabiting) StateFactory.fromJSONObject(StateLabel.Cohabiting, o);
+
+ Assert.assertEquals(StateLabel.Cohabiting, state.stateLabel);
+ Assert.assertEquals("uid", state.uid);
+ Assert.assertEquals("4e2830da6ce466ddb401fbca25b96a621209eea83851254800f84cc4069ef011", Utils.byte2Hex(state.sessionToken));
+ Assert.assertEquals("DS128", state.keyPair.getPrivate().getAlgorithm());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/helpers/AssertUtil.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/helpers/AssertUtil.java
new file mode 100644
index 000000000..8102bf1ee
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/helpers/AssertUtil.java
@@ -0,0 +1,29 @@
+/* -*- 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.helpers;
+
+import org.junit.Assert;
+
+/**
+ * Some additional assert methods on top of org.junit.Assert.
+ */
+public class AssertUtil {
+ /**
+ * Asserts that the String {@code text} contains the String {@code sequence}. If it doesn't then
+ * an {@link AssertionError} will be thrown.
+ */
+ public static void assertContains(String text, String sequence) {
+ Assert.assertTrue(text.contains(sequence));
+ }
+
+ /**
+ * Asserts that the String {@code text} contains not the String {@code sequence}. If it does
+ * then an {@link AssertionError} will be thrown.
+ */
+ public static void assertContainsNot(String text, String sequence) {
+ Assert.assertFalse(text.contains(sequence));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/home/TestHomeConfigPrefsBackendMigration.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/home/TestHomeConfigPrefsBackendMigration.java
new file mode 100644
index 000000000..b6f12a05e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/home/TestHomeConfigPrefsBackendMigration.java
@@ -0,0 +1,264 @@
+/* -*- 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.home;
+
+import android.content.Context;
+import android.util.Pair;
+import android.util.SparseArray;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.HomeConfig.PanelType;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class TestHomeConfigPrefsBackendMigration {
+
+ // Each Pair consists of a list of panels that exist going into a given migration, and a list containing
+ // the expected default output panel corresponding to each given input panel in the list of existing panels.
+ // E.g. if a given N->N+1 migration starts with panels Foo and Bar, and removes Bar, the two lists would
+ // be {Foo, Bar} and {Foo, Foo}.
+ // Note: the index where each pair is inserted corresponds to the HomeConfig version before the migration.
+ // The final item in this list denotes the current HomeCOnfig version, and therefore only needs to contain
+ // the list of panel types that are expected by default (but no list for after the non-existent migration).
+ final SparseArray<Pair<PanelType[], PanelType[]>> migrationConstellations = new SparseArray<>();
+ {
+ // 6->7: the recent tabs panel was merged into the combined history panel
+ migrationConstellations.put(6, new Pair<>(
+ /* Panels that are expected to exist before this migration happens */
+ new PanelType[] {
+ PanelType.TOP_SITES,
+ PanelType.BOOKMARKS,
+ PanelType.COMBINED_HISTORY,
+ PanelType.DEPRECATED_RECENT_TABS
+ },
+ /* The expected default panel that is expected after the migration */
+ new PanelType[] {
+ PanelType.TOP_SITES, /* TOP_SITES remains the default if it was previously the default */
+ PanelType.BOOKMARKS, /* same as TOP_SITES */
+ PanelType.COMBINED_HISTORY, /* same as TOP_SITES */
+ PanelType.COMBINED_HISTORY /* DEPRECATED_RECENT_TABS is replaced by COMBINED_HISTORY during this migration and is therefore the new default */
+ }
+ ));
+
+ // 7->8: no changes, this was a fixup migration since 6->7 was previously botched
+ migrationConstellations.put(7, new Pair<>(
+ new PanelType[] {
+ PanelType.TOP_SITES,
+ PanelType.BOOKMARKS,
+ PanelType.COMBINED_HISTORY,
+ },
+ new PanelType[] {
+ PanelType.TOP_SITES,
+ PanelType.BOOKMARKS,
+ PanelType.COMBINED_HISTORY,
+ }
+ ));
+
+ migrationConstellations.put(8, new Pair<>(
+ new PanelType[] {
+ PanelType.TOP_SITES,
+ PanelType.BOOKMARKS,
+ PanelType.COMBINED_HISTORY,
+ },
+ new PanelType[] {
+ // Last version: no migration exists yet, we only need to define a list
+ // of expected panels.
+ }
+ ));
+ }
+
+ private JSONArray createDisabledConfigsForList(Context context,
+ PanelType[] panels) throws JSONException {
+ final JSONArray jsonPanels = new JSONArray();
+
+ for (int i = 0; i < panels.length; i++) {
+ final PanelType panel = panels[i];
+
+ jsonPanels.put(HomeConfig.createBuiltinPanelConfig(context, panel,
+ EnumSet.of(PanelConfig.Flags.DISABLED_PANEL)).toJSON());
+ }
+
+ return jsonPanels;
+
+ }
+
+
+ private JSONArray createConfigsForList(Context context, PanelType[] panels,
+ int defaultIndex) throws JSONException {
+ if (defaultIndex < 0 || defaultIndex >= panels.length) {
+ throw new IllegalArgumentException("defaultIndex must point to panel in the array");
+ }
+
+ final JSONArray jsonPanels = new JSONArray();
+
+ for (int i = 0; i < panels.length; i++) {
+ final PanelType panel = panels[i];
+ final PanelConfig config;
+
+ if (i == defaultIndex) {
+ config = HomeConfig.createBuiltinPanelConfig(context, panel,
+ EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL));
+ } else {
+ config = HomeConfig.createBuiltinPanelConfig(context, panel);
+ }
+
+ jsonPanels.put(config.toJSON());
+ }
+
+ return jsonPanels;
+ }
+
+ private PanelType getDefaultPanel(final JSONArray jsonPanels) throws JSONException {
+ assertTrue("panel list must not be empty", jsonPanels.length() > 0);
+
+ for (int i = 0; i < jsonPanels.length(); i++) {
+ final JSONObject jsonPanelConfig = jsonPanels.getJSONObject(i);
+ final PanelConfig panelConfig = new PanelConfig(jsonPanelConfig);
+
+ if (panelConfig.isDefault()) {
+ return panelConfig.getType();
+ }
+ }
+
+ return null;
+ }
+
+ private void checkAllPanelsAreDisabled(JSONArray jsonPanels) throws JSONException {
+ for (int i = 0; i < jsonPanels.length(); i++) {
+ final JSONObject jsonPanelConfig = jsonPanels.getJSONObject(i);
+ final PanelConfig config = new PanelConfig(jsonPanelConfig);
+
+ assertTrue("Non disabled panel \"" + config.getType().name() + "\" found in list, excpected all panels to be disabled", config.isDisabled());
+ }
+ }
+
+ private void checkListContainsExpectedPanels(JSONArray jsonPanels,
+ PanelType[] expected) throws JSONException {
+ // Given the short lists we have here an ArraySet might be more appropriate, but it requires API >= 23.
+ final Set<PanelType> expectedSet = new HashSet<>();
+ for (PanelType panelType : expected) {
+ expectedSet.add(panelType);
+ }
+
+ for (int i = 0; i < jsonPanels.length(); i++) {
+ final JSONObject jsonPanelConfig = jsonPanels.getJSONObject(i);
+ final PanelType panelType = new PanelConfig(jsonPanelConfig).getType();
+
+ assertTrue("Unexpected panel of type " + panelType.name() + " found in list",
+ expectedSet.contains(panelType));
+
+ expectedSet.remove(panelType);
+ }
+
+ assertEquals("Expected panels not contained in list",
+ 0, expectedSet.size());
+ }
+
+ @Test
+ public void testMigrationRetainsDefaultAfter6() throws JSONException {
+ final Context context = RuntimeEnvironment.application;
+
+ final Pair<PanelType[], PanelType[]> finalConstellation = migrationConstellations.get(HomeConfigPrefsBackend.VERSION);
+ assertNotNull("It looks like you added a HomeConfig migration, please add an appropriate entry to migrationConstellations",
+ finalConstellation);
+
+ // We want to calculate the number of iterations here to make sure we cover all provided constellations.
+ // Iterating over the array and manually checking for each version could result in constellations
+ // being skipped if there are any gaps in the array
+ final int firstTestedVersion = HomeConfigPrefsBackend.VERSION - (migrationConstellations.size() - 1);
+
+ // The last constellation is only used for the counts / expected outputs, hence we start
+ // with the second-last constellation
+ for (int testVersion = HomeConfigPrefsBackend.VERSION - 1; testVersion >= firstTestedVersion; testVersion--) {
+
+ final Pair<PanelType[], PanelType[]> currentConstellation = migrationConstellations.get(testVersion);
+ assertNotNull("No constellation for version " + testVersion + " - you must provide a constellation for every version upgrade in the list",
+ currentConstellation);
+
+ final PanelType[] inputList = currentConstellation.first;
+ final PanelType[] expectedDefaults = currentConstellation.second;
+
+ for (int i = 0; i < inputList.length; i++) {
+ JSONArray jsonPanels = createConfigsForList(context, inputList, i);
+
+
+ // Verify that we still have a default panel, and that it is the expected default panel
+
+ // No need to pass in the prefsEditor since that is only used for the 0->1 migration
+ jsonPanels = HomeConfigPrefsBackend.migratePrefsFromVersionToVersion(context, testVersion, testVersion + 1, jsonPanels, null);
+
+ final PanelType oldDefaultPanelType = inputList[i];
+ final PanelType expectedNewDefaultPanelType = expectedDefaults[i];
+ final PanelType newDefaultPanelType = getDefaultPanel(jsonPanels);
+
+ assertNotNull("No default panel set when migrating from " + testVersion + " to " + testVersion + 1 + ", with previous default as " + oldDefaultPanelType.name(),
+ newDefaultPanelType);
+
+ assertEquals("Migration changed to unexpected default panel - migrating from " + oldDefaultPanelType.name() + ", expected " + expectedNewDefaultPanelType.name() + " but got " + newDefaultPanelType.name(),
+ newDefaultPanelType, expectedNewDefaultPanelType);
+
+
+ // Verify that the panels remaining after the migration correspond to the input panels
+ // for the next migration
+ final PanelType[] expectedOutputList = migrationConstellations.get(testVersion + 1).first;
+
+ assertEquals("Number of panels after migration doesn't match expected count",
+ jsonPanels.length(), expectedOutputList.length);
+
+ checkListContainsExpectedPanels(jsonPanels, expectedOutputList);
+ }
+ }
+ }
+
+ // Test that if all panels are disabled, the migration retains all panels as being disabled
+ // (in addition to correctly removing panels as necessary).
+ @Test
+ public void testMigrationRetainsAllPanelsHiddenAfter6() throws JSONException {
+ final Context context = RuntimeEnvironment.application;
+
+ final Pair<PanelType[], PanelType[]> finalConstellation = migrationConstellations.get(HomeConfigPrefsBackend.VERSION);
+ assertNotNull("It looks like you added a HomeConfig migration, please add an appropriate entry to migrationConstellations",
+ finalConstellation);
+
+ final int firstTestedVersion = HomeConfigPrefsBackend.VERSION - (migrationConstellations.size() - 1);
+
+ for (int testVersion = HomeConfigPrefsBackend.VERSION - 1; testVersion >= firstTestedVersion; testVersion--) {
+ final Pair<PanelType[], PanelType[]> currentConstellation = migrationConstellations.get(testVersion);
+ assertNotNull("No constellation for version " + testVersion + " - you must provide a constellation for every version upgrade in the list",
+ currentConstellation);
+
+ final PanelType[] inputList = currentConstellation.first;
+
+ JSONArray jsonPanels = createDisabledConfigsForList(context, inputList);
+
+ jsonPanels = HomeConfigPrefsBackend.migratePrefsFromVersionToVersion(context, testVersion, testVersion + 1, jsonPanels, null);
+
+ // All panels should remain disabled after the migration
+ checkAllPanelsAreDisabled(jsonPanels);
+
+ // Duplicated from previous test:
+ // Verify that the panels remaining after the migration correspond to the input panels
+ // for the next migration
+ final PanelType[] expectedOutputList = migrationConstellations.get(testVersion + 1).first;
+
+ assertEquals("Number of panels after migration doesn't match expected count",
+ jsonPanels.length(), expectedOutputList.length);
+
+ checkListContainsExpectedPanels(jsonPanels, expectedOutputList);
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptor.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptor.java
new file mode 100644
index 000000000..05e4576e5
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptor.java
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+@RunWith(TestRunner.class)
+public class TestIconDescriptor {
+ private static final String ICON_URL = "https://www.mozilla.org/favicon.ico";
+ private static final String MIME_TYPE = "image/png";
+ private static final int ICON_SIZE = 64;
+
+ @Test
+ public void testGenericIconDescriptor() {
+ final IconDescriptor descriptor = IconDescriptor.createGenericIcon(ICON_URL);
+
+ Assert.assertEquals(ICON_URL, descriptor.getUrl());
+ Assert.assertNull(descriptor.getMimeType());
+ Assert.assertEquals(0, descriptor.getSize());
+ Assert.assertEquals(IconDescriptor.TYPE_GENERIC, descriptor.getType());
+ }
+
+ @Test
+ public void testFaviconIconDescriptor() {
+ final IconDescriptor descriptor = IconDescriptor.createFavicon(ICON_URL, ICON_SIZE, MIME_TYPE);
+
+ Assert.assertEquals(ICON_URL, descriptor.getUrl());
+ Assert.assertEquals(MIME_TYPE, descriptor.getMimeType());
+ Assert.assertEquals(ICON_SIZE, descriptor.getSize());
+ Assert.assertEquals(IconDescriptor.TYPE_FAVICON, descriptor.getType());
+ }
+
+ @Test
+ public void testTouchIconDescriptor() {
+ final IconDescriptor descriptor = IconDescriptor.createTouchicon(ICON_URL, ICON_SIZE, MIME_TYPE);
+
+ Assert.assertEquals(ICON_URL, descriptor.getUrl());
+ Assert.assertEquals(MIME_TYPE, descriptor.getMimeType());
+ Assert.assertEquals(ICON_SIZE, descriptor.getSize());
+ Assert.assertEquals(IconDescriptor.TYPE_TOUCHICON, descriptor.getType());
+ }
+
+ @Test
+ public void testLookupIconDescriptor() {
+ final IconDescriptor descriptor = IconDescriptor.createLookupIcon(ICON_URL);
+
+ Assert.assertEquals(ICON_URL, descriptor.getUrl());
+ Assert.assertNull(descriptor.getMimeType());
+ Assert.assertEquals(0, descriptor.getSize());
+ Assert.assertEquals(IconDescriptor.TYPE_LOOKUP, descriptor.getType());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptorComparator.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptorComparator.java
new file mode 100644
index 000000000..1f4664d08
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconDescriptorComparator.java
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import org.junit.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import java.util.TreeSet;
+
+@RunWith(TestRunner.class)
+public class TestIconDescriptorComparator {
+ private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico";
+ private static final String TEST_ICON_URL_2 = "http://www.example.org/favicon.ico";
+ private static final String TEST_ICON_URL_3 = "http://www.example.com/favicon.ico";
+
+ private static final String TEST_MIME_TYPE = "image/png";
+ private static final int TEST_SIZE = 32;
+
+ @Test
+ public void testIconsWithTheSameUrlAreTreatedAsEqual() {
+ final IconDescriptor descriptor1 = IconDescriptor.createGenericIcon(TEST_ICON_URL_1);
+ final IconDescriptor descriptor2 = IconDescriptor.createGenericIcon(TEST_ICON_URL_1);
+
+ final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+ Assert.assertEquals(0, comparator.compare(descriptor1, descriptor2));
+ Assert.assertEquals(0, comparator.compare(descriptor2, descriptor1));
+ }
+
+ @Test
+ public void testTouchIconsAreRankedHigherThanFavicons() {
+ final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_1, TEST_SIZE, TEST_MIME_TYPE);
+ final IconDescriptor touchIconDescriptor = IconDescriptor.createTouchicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE);
+
+ final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+ Assert.assertEquals(1, comparator.compare(faviconDescriptor, touchIconDescriptor));
+ Assert.assertEquals(-1, comparator.compare(touchIconDescriptor, faviconDescriptor));
+ }
+
+ @Test
+ public void testFaviconsAndTouchIconsAreRankedHigherThanGenericIcons() {
+ final IconDescriptor genericDescriptor = IconDescriptor.createGenericIcon(TEST_ICON_URL_1);
+
+ final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE);
+ final IconDescriptor touchIconDescriptor = IconDescriptor.createTouchicon(TEST_ICON_URL_3, TEST_SIZE, TEST_MIME_TYPE);
+
+ final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+ Assert.assertEquals(1, comparator.compare(genericDescriptor, faviconDescriptor));
+ Assert.assertEquals(-1, comparator.compare(faviconDescriptor, genericDescriptor));
+
+ Assert.assertEquals(1, comparator.compare(genericDescriptor, touchIconDescriptor));
+ Assert.assertEquals(-1, comparator.compare(touchIconDescriptor, genericDescriptor));
+ }
+
+ @Test
+ public void testLookupIconsAreRankedHigherThanGenericIcons() {
+ final IconDescriptor genericDescriptor = IconDescriptor.createGenericIcon(TEST_ICON_URL_1);
+ final IconDescriptor lookupDescriptor = IconDescriptor.createLookupIcon(TEST_ICON_URL_2);
+
+ final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+ Assert.assertEquals(1, comparator.compare(genericDescriptor, lookupDescriptor));
+ Assert.assertEquals(-1, comparator.compare(lookupDescriptor, genericDescriptor));
+ }
+
+ @Test
+ public void testFaviconsAndTouchIconsAreRankedHigherThanLookupIcons() {
+ final IconDescriptor lookupDescriptor = IconDescriptor.createLookupIcon(TEST_ICON_URL_1);
+
+ final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE);
+ final IconDescriptor touchIconDescriptor = IconDescriptor.createTouchicon(TEST_ICON_URL_3, TEST_SIZE, TEST_MIME_TYPE);
+
+ final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+ Assert.assertEquals(1, comparator.compare(lookupDescriptor, faviconDescriptor));
+ Assert.assertEquals(-1, comparator.compare(faviconDescriptor, lookupDescriptor));
+
+ Assert.assertEquals(1, comparator.compare(lookupDescriptor, touchIconDescriptor));
+ Assert.assertEquals(-1, comparator.compare(touchIconDescriptor, lookupDescriptor));
+ }
+
+ @Test
+ public void testLargestIconOfSameTypeIsSelected() {
+ final IconDescriptor smallDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_1, 16, TEST_MIME_TYPE);
+ final IconDescriptor largeDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, 128, TEST_MIME_TYPE);
+
+ final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+ Assert.assertEquals(1, comparator.compare(smallDescriptor, largeDescriptor));
+ Assert.assertEquals(-1, comparator.compare(largeDescriptor, smallDescriptor));
+ }
+
+ @Test
+ public void testContainerTypesArePreferred() {
+ final IconDescriptor containerDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_1, TEST_SIZE, "image/x-icon");
+ final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, "image/png");
+
+ final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+ Assert.assertEquals(1, comparator.compare(faviconDescriptor, containerDescriptor));
+ Assert.assertEquals(-1, comparator.compare(containerDescriptor, faviconDescriptor));
+ }
+
+ @Test
+ public void testWithNoDifferences() {
+ final IconDescriptor descriptor1 = IconDescriptor.createFavicon(TEST_ICON_URL_1, TEST_SIZE, TEST_MIME_TYPE);
+ final IconDescriptor descriptor2 = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE);
+
+ final IconDescriptorComparator comparator = new IconDescriptorComparator();
+
+ Assert.assertNotEquals(0, comparator.compare(descriptor1, descriptor2));
+ Assert.assertNotEquals(0, comparator.compare(descriptor2, descriptor1));
+ }
+
+ @Test
+ public void testWithSameObject() {
+ final IconDescriptor descriptor = IconDescriptor.createTouchicon(TEST_ICON_URL_1, TEST_SIZE, TEST_MIME_TYPE);
+
+ final IconDescriptorComparator comparator = new IconDescriptorComparator();
+ Assert.assertEquals(0, comparator.compare(descriptor, descriptor));
+ }
+
+ /**
+ * This test reconstructs the scenario from bug 1331808. A comparator implementation that does
+ * not return a consistent order can break the implementation of remove() of the TreeSet class.
+ */
+ @Test
+ public void testBug1331808() {
+ TreeSet<IconDescriptor> set = new TreeSet<>(new IconDescriptorComparator());
+
+ set.add(IconDescriptor.createFavicon("http://example.org/new-logo32.jpg", 0, ""));
+ set.add(IconDescriptor.createTouchicon("http://example.org/new-logo57.jpg", 0, ""));
+ set.add(IconDescriptor.createTouchicon("http://example.org/new-logo76.jpg", 76, ""));
+ set.add(IconDescriptor.createTouchicon("http://example.org/new-logo120.jpg", 120, ""));
+ set.add(IconDescriptor.createTouchicon("http://example.org/new-logo152.jpg", 114, ""));
+ set.add(IconDescriptor.createFavicon("http://example.org/02.png", 32, ""));
+ set.add(IconDescriptor.createFavicon("http://example.org/01.png", 192, ""));
+ set.add(IconDescriptor.createTouchicon("http://example.org/03.png", 0, ""));
+
+ for (int i = 8; i > 0; i--) {
+ Assert.assertEquals("items in set before deleting: " + i, i, set.size());
+ Assert.assertTrue("item removed successfully: " + i, set.remove(set.first()));
+ Assert.assertEquals("items in set after deleting: " + i, i - 1, set.size());
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequest.java
new file mode 100644
index 000000000..d77ad6a53
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequest.java
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import junit.framework.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.TreeSet;
+
+import static org.hamcrest.Matchers.any;
+import static org.mockito.Matchers.anyObject;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestIconRequest {
+ private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+ private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico";
+ private static final String TEST_ICON_URL_2 = "http://www.example.org/favicon.ico";
+
+ @Test
+ public void testIconHandling() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .build();
+
+ Assert.assertEquals(0, request.getIconCount());
+ Assert.assertFalse(request.hasIconDescriptors());
+
+ request.modify()
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_1))
+ .deferBuild();
+
+ Assert.assertEquals(1, request.getIconCount());
+ Assert.assertTrue(request.hasIconDescriptors());
+
+ request.modify()
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_2))
+ .deferBuild();
+
+ Assert.assertEquals(2, request.getIconCount());
+ Assert.assertTrue(request.hasIconDescriptors());
+
+ Assert.assertEquals(TEST_ICON_URL_2, request.getBestIcon().getUrl());
+
+ request.moveToNextIcon();
+
+ Assert.assertEquals(1, request.getIconCount());
+ Assert.assertTrue(request.hasIconDescriptors());
+
+ Assert.assertEquals(TEST_ICON_URL_1, request.getBestIcon().getUrl());
+
+ request.moveToNextIcon();
+
+ Assert.assertEquals(0, request.getIconCount());
+ Assert.assertFalse(request.hasIconDescriptors());
+ }
+
+ /**
+ * If removing an icon from the internal set failed then we want to throw an exception.
+ */
+ @Test(expected = IllegalStateException.class)
+ public void testMoveToNextIconThrowsException() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .build();
+
+ //noinspection unchecked - Creating a mock of a generic type
+ request.icons = (TreeSet<IconDescriptor>) mock(TreeSet.class);
+
+ //noinspection SuspiciousMethodCalls
+ doReturn(false).when(request.icons).remove(anyObject());
+
+ request.moveToNextIcon();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequestBuilder.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequestBuilder.java
new file mode 100644
index 000000000..0743b42d8
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconRequestBuilder.java
@@ -0,0 +1,159 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import org.junit.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestIconRequestBuilder {
+ private static final String TEST_PAGE_URL_1 = "http://www.mozilla.org";
+ private static final String TEST_PAGE_URL_2 = "http://www.example.org";
+ private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico";
+ private static final String TEST_ICON_URL_2 = "http://www.example.org/favicon.ico";
+
+ @Test
+ public void testPrivileged() {
+ IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL_1)
+ .build();
+
+ Assert.assertFalse(request.isPrivileged());
+
+ request.modify()
+ .privileged(true)
+ .deferBuild();
+
+ Assert.assertTrue(request.isPrivileged());
+ }
+
+ @Test
+ public void testPageUrl() {
+ IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL_1)
+ .build();
+
+ Assert.assertEquals(TEST_PAGE_URL_1, request.getPageUrl());
+
+ request.modify()
+ .pageUrl(TEST_PAGE_URL_2)
+ .deferBuild();
+
+ Assert.assertEquals(TEST_PAGE_URL_2, request.getPageUrl());
+ }
+
+ @Test
+ public void testIcons() {
+ // Initially a request is empty.
+ IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL_1)
+ .build();
+
+ Assert.assertEquals(0, request.getIconCount());
+
+ // Adding one icon URL.
+ request.modify()
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_1))
+ .deferBuild();
+
+ Assert.assertEquals(1, request.getIconCount());
+
+ // Adding the same icon URL again is ignored.
+ request.modify()
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_1))
+ .deferBuild();
+
+ Assert.assertEquals(1, request.getIconCount());
+
+ // Adding another new icon URL.
+ request.modify()
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_2))
+ .deferBuild();
+
+ Assert.assertEquals(2, request.getIconCount());
+ }
+
+ @Test
+ public void testSkipNetwork() {
+ IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL_1)
+ .build();
+
+ Assert.assertFalse(request.shouldSkipNetwork());
+
+ request.modify()
+ .skipNetwork()
+ .deferBuild();
+
+ Assert.assertTrue(request.shouldSkipNetwork());
+ }
+
+ @Test
+ public void testSkipDisk() {
+ IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL_1)
+ .build();
+
+ Assert.assertFalse(request.shouldSkipDisk());
+
+ request.modify()
+ .skipDisk()
+ .deferBuild();
+
+ Assert.assertTrue(request.shouldSkipDisk());
+ }
+
+ @Test
+ public void testSkipMemory() {
+ IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL_1)
+ .build();
+
+ Assert.assertFalse(request.shouldSkipMemory());
+
+ request.modify()
+ .skipMemory()
+ .deferBuild();
+
+ Assert.assertTrue(request.shouldSkipMemory());
+ }
+
+ @Test
+ public void testExecutionOnBackgroundThread() {
+ IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL_1)
+ .build();
+
+ Assert.assertFalse(request.shouldRunOnBackgroundThread());
+
+ request.modify()
+ .executeCallbackOnBackgroundThread()
+ .deferBuild();
+
+ Assert.assertTrue(request.shouldRunOnBackgroundThread());
+ }
+
+ @Test
+ public void testForLauncherIcon() {
+ // This code will call into GeckoAppShell to determine the launcher icon size for this configuration
+ GeckoAppShell.setApplicationContext(RuntimeEnvironment.application);
+
+ IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL_1)
+ .build();
+
+ Assert.assertEquals(32, request.getTargetSize());
+
+ request.modify()
+ .forLauncherIcon()
+ .deferBuild();
+
+ Assert.assertEquals(48, request.getTargetSize());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconResponse.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconResponse.java
new file mode 100644
index 000000000..4c7faa4f8
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconResponse.java
@@ -0,0 +1,148 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestIconResponse {
+ private static final String ICON_URL = "http://www.mozilla.org/favicon.ico";
+
+ @Test
+ public void testDefaultResponse() {
+ final Bitmap bitmap = mock(Bitmap.class);
+
+ final IconResponse response = IconResponse.create(bitmap);
+
+ Assert.assertEquals(bitmap, response.getBitmap());
+ Assert.assertFalse(response.hasUrl());
+ Assert.assertNull(response.getUrl());
+
+ Assert.assertFalse(response.hasColor());
+ Assert.assertEquals(0, response.getColor());
+
+ Assert.assertFalse(response.isGenerated());
+ Assert.assertFalse(response.isFromNetwork());
+ Assert.assertFalse(response.isFromDisk());
+ Assert.assertFalse(response.isFromMemory());
+ }
+
+ @Test
+ public void testNetworkResponse() {
+ final Bitmap bitmap = mock(Bitmap.class);
+
+ final IconResponse response = IconResponse.createFromNetwork(bitmap, ICON_URL);
+
+ Assert.assertEquals(bitmap, response.getBitmap());
+ Assert.assertTrue(response.hasUrl());
+ Assert.assertEquals(ICON_URL, response.getUrl());
+
+ Assert.assertFalse(response.hasColor());
+ Assert.assertEquals(0, response.getColor());
+
+ Assert.assertFalse(response.isGenerated());
+ Assert.assertTrue(response.isFromNetwork());
+ Assert.assertFalse(response.isFromDisk());
+ Assert.assertFalse(response.isFromMemory());
+ }
+
+ @Test
+ public void testGeneratedResponse() {
+ final Bitmap bitmap = mock(Bitmap.class);
+
+ final IconResponse response = IconResponse.createGenerated(bitmap, Color.CYAN);
+
+ Assert.assertEquals(bitmap, response.getBitmap());
+ Assert.assertFalse(response.hasUrl());
+ Assert.assertNull(response.getUrl());
+
+ Assert.assertTrue(response.hasColor());
+ Assert.assertEquals(Color.CYAN, response.getColor());
+
+ Assert.assertTrue(response.isGenerated());
+ Assert.assertFalse(response.isFromNetwork());
+ Assert.assertFalse(response.isFromDisk());
+ Assert.assertFalse(response.isFromMemory());
+ }
+
+ @Test
+ public void testMemoryResponse() {
+ final Bitmap bitmap = mock(Bitmap.class);
+
+ final IconResponse response = IconResponse.createFromMemory(bitmap, ICON_URL, Color.CYAN);
+
+ Assert.assertEquals(bitmap, response.getBitmap());
+ Assert.assertTrue(response.hasUrl());
+ Assert.assertEquals(ICON_URL, response.getUrl());
+
+ Assert.assertTrue(response.hasColor());
+ Assert.assertEquals(Color.CYAN, response.getColor());
+
+ Assert.assertFalse(response.isGenerated());
+ Assert.assertFalse(response.isFromNetwork());
+ Assert.assertFalse(response.isFromDisk());
+ Assert.assertTrue(response.isFromMemory());
+ }
+
+ @Test
+ public void testDiskResponse() {
+ final Bitmap bitmap = mock(Bitmap.class);
+
+ final IconResponse response = IconResponse.createFromDisk(bitmap, ICON_URL);
+
+ Assert.assertEquals(bitmap, response.getBitmap());
+ Assert.assertTrue(response.hasUrl());
+ Assert.assertEquals(ICON_URL, response.getUrl());
+
+ Assert.assertFalse(response.hasColor());
+ Assert.assertEquals(0, response.getColor());
+
+ Assert.assertFalse(response.isGenerated());
+ Assert.assertFalse(response.isFromNetwork());
+ Assert.assertTrue(response.isFromDisk());
+ Assert.assertFalse(response.isFromMemory());
+ }
+
+ @Test
+ public void testUpdatingColor() {
+ final IconResponse response = IconResponse.create(mock(Bitmap.class));
+
+ Assert.assertFalse(response.hasColor());
+ Assert.assertEquals(0, response.getColor());
+
+ response.updateColor(Color.YELLOW);
+
+ Assert.assertTrue(response.hasColor());
+ Assert.assertEquals(Color.YELLOW, response.getColor());
+
+ response.updateColor(Color.MAGENTA);
+
+ Assert.assertTrue(response.hasColor());
+ Assert.assertEquals(Color.MAGENTA, response.getColor());
+ }
+
+ @Test
+ public void testUpdatingBitmap() {
+ final Bitmap originalBitmap = mock(Bitmap.class);
+ final Bitmap updatedBitmap = mock(Bitmap.class);
+
+ final IconResponse response = IconResponse.create(originalBitmap);
+
+ Assert.assertEquals(originalBitmap, response.getBitmap());
+ Assert.assertNotEquals(updatedBitmap, response.getBitmap());
+
+ response.updateBitmap(updatedBitmap);
+
+ Assert.assertNotEquals(originalBitmap, response.getBitmap());
+ Assert.assertEquals(updatedBitmap, response.getBitmap());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconTask.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconTask.java
new file mode 100644
index 000000000..77a801988
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconTask.java
@@ -0,0 +1,575 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import android.graphics.Bitmap;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.loader.IconLoader;
+import org.mozilla.gecko.icons.preparation.Preparer;
+import org.mozilla.gecko.icons.processing.Processor;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+@RunWith(TestRunner.class)
+public class TestIconTask {
+ @Test
+ public void testGeneratorIsInvokedIfAllLoadersFail() {
+ final List<IconLoader> loaders = Arrays.asList(
+ createFailingLoader(),
+ createFailingLoader(),
+ createFailingLoader());
+
+ final Bitmap bitmap = mock(Bitmap.class);
+ final IconLoader generator = createSuccessfulLoader(bitmap);
+
+ final IconRequest request = createIconRequest();
+
+ final IconTask task = new IconTask(
+ request,
+ Collections.<Preparer>emptyList(),
+ loaders,
+ Collections.<Processor>emptyList(),
+ generator);
+
+ final IconResponse response = task.call();
+
+ // Verify all loaders have been tried
+ for (IconLoader loader : loaders) {
+ verify(loader).load(request);
+ }
+
+ // Verify generator was called
+ verify(generator).load(request);
+
+ // Verify response contains generated bitmap
+ Assert.assertEquals(bitmap, response.getBitmap());
+ }
+
+ @Test
+ public void testGeneratorIsNotCalledIfOneLoaderWasSuccessful() {
+ final List<IconLoader> loaders = Collections.singletonList(
+ createSuccessfulLoader(mock(Bitmap.class)));
+
+ final IconLoader generator = createSuccessfulLoader(mock(Bitmap.class));
+
+ final IconRequest request = createIconRequest();
+
+ final IconTask task = new IconTask(
+ request,
+ Collections.<Preparer>emptyList(),
+ loaders,
+ Collections.<Processor>emptyList(),
+ generator);
+
+ final IconResponse response = task.call();
+
+ // Verify all loaders have been tried
+ for (IconLoader loader : loaders) {
+ verify(loader).load(request);
+ }
+
+ // Verify generator was NOT called
+ verify(generator, never()).load(request);
+
+ Assert.assertNotNull(response);
+ }
+
+ @Test
+ public void testNoLoaderIsInvokedForRequestWithoutUrls() {
+ final List<IconLoader> loaders = Collections.singletonList(
+ createSuccessfulLoader(mock(Bitmap.class)));
+
+ final Bitmap bitmap = mock(Bitmap.class);
+ final IconLoader generator = createSuccessfulLoader(bitmap);
+
+ final IconRequest request = createIconRequestWithoutUrls();
+
+ final IconTask task = new IconTask(
+ request,
+ Collections.<Preparer>emptyList(),
+ loaders,
+ Collections.<Processor>emptyList(),
+ generator);
+
+ final IconResponse response = task.call();
+
+ // Verify NO loaders have been called
+ for (IconLoader loader : loaders) {
+ verify(loader, never()).load(request);
+ }
+
+ // Verify generator was called
+ verify(generator).load(request);
+
+ // Verify response contains generated bitmap
+ Assert.assertEquals(bitmap, response.getBitmap());
+ }
+
+ @Test
+ public void testAllPreparersAreCalledBeforeLoading() {
+ final List<Preparer> preparers = Arrays.asList(
+ mock(Preparer.class),
+ mock(Preparer.class),
+ mock(Preparer.class),
+ mock(Preparer.class),
+ mock(Preparer.class),
+ mock(Preparer.class));
+
+ final IconRequest request = createIconRequest();
+
+ final IconTask task = new IconTask(
+ request,
+ preparers,
+ createListWithSuccessfulLoader(),
+ Collections.<Processor>emptyList(),
+ createGenerator());
+
+ task.call();
+
+ // Verify all preparers have been called
+ for (Preparer preparer : preparers) {
+ verify(preparer).prepare(request);
+ }
+ }
+
+ @Test
+ public void testSubsequentLoadersAreNotCalledAfterSuccessfulLoad() {
+ final Bitmap bitmap = mock(Bitmap.class);
+
+ final List<IconLoader> loaders = Arrays.asList(
+ createFailingLoader(),
+ createFailingLoader(),
+ createSuccessfulLoader(bitmap),
+ createSuccessfulLoader(mock(Bitmap.class)),
+ createFailingLoader(),
+ createSuccessfulLoader(mock(Bitmap.class)));
+
+ final IconRequest request = createIconRequest();
+
+ final IconTask task = new IconTask(
+ request,
+ Collections.<Preparer>emptyList(),
+ loaders,
+ Collections.<Processor>emptyList(),
+ createGenerator());
+
+ final IconResponse response = task.call();
+
+ // First loaders are called
+ verify(loaders.get(0)).load(request);
+ verify(loaders.get(1)).load(request);
+ verify(loaders.get(2)).load(request);
+
+ // Loaders after successful load are not called
+ verify(loaders.get(3), never()).load(request);
+ verify(loaders.get(4), never()).load(request);
+ verify(loaders.get(5), never()).load(request);
+
+ Assert.assertNotNull(response);
+ Assert.assertEquals(bitmap, response.getBitmap());
+ }
+
+ @Test
+ public void testNoProcessorIsCalledForUnsuccessfulLoads() {
+ final IconRequest request = createIconRequest();
+
+ final List<IconLoader> loaders = createListWithFailingLoaders();
+
+ final List<Processor> processors = Arrays.asList(
+ createProcessor(),
+ createProcessor(),
+ createProcessor());
+
+ final IconTask task = new IconTask(
+ request,
+ Collections.<Preparer>emptyList(),
+ loaders,
+ processors,
+ createFailingLoader());
+
+ task.call();
+
+ // Verify all loaders have been tried
+ for (IconLoader loader : loaders) {
+ verify(loader).load(request);
+ }
+
+ // Verify no processor was called
+ for (Processor processor : processors) {
+ verify(processor, never()).process(any(IconRequest.class), any(IconResponse.class));
+ }
+ }
+
+ @Test
+ public void testAllProcessorsAreCalledAfterSuccessfulLoad() {
+ final IconRequest request = createIconRequest();
+
+ final List<Processor> processors = Arrays.asList(
+ createProcessor(),
+ createProcessor(),
+ createProcessor());
+
+ final IconTask task = new IconTask(
+ request,
+ Collections.<Preparer>emptyList(),
+ createListWithSuccessfulLoader(),
+ processors,
+ createGenerator());
+
+ IconResponse response = task.call();
+
+ Assert.assertNotNull(response);
+
+ // Verify that all processors have been called
+ for (Processor processor : processors) {
+ verify(processor).process(request, response);
+ }
+ }
+
+ @Test
+ public void testCallbackIsExecutedForSuccessfulLoads() {
+ final IconCallback callback = mock(IconCallback.class);
+
+ final IconRequest request = createIconRequest();
+ request.setCallback(callback);
+
+ final IconTask task = new IconTask(
+ request,
+ Collections.<Preparer>emptyList(),
+ createListWithSuccessfulLoader(),
+ Collections.<Processor>emptyList(),
+ createGenerator());
+
+ final IconResponse response = task.call();
+
+ verify(callback).onIconResponse(response);
+ }
+
+ @Test
+ public void testCallbackIsNotExecutedIfLoadingFailed() {
+ final IconCallback callback = mock(IconCallback.class);
+
+ final IconRequest request = createIconRequest();
+ request.setCallback(callback);
+
+ final IconTask task = new IconTask(
+ request,
+ Collections.<Preparer>emptyList(),
+ createListWithFailingLoaders(),
+ Collections.<Processor>emptyList(),
+ createFailingLoader());
+
+ task.call();
+
+ verify(callback, never()).onIconResponse(any(IconResponse.class));
+ }
+
+ @Test
+ public void testCallbackIsExecutedWithGeneratorResult() {
+ final IconCallback callback = mock(IconCallback.class);
+
+ final IconRequest request = createIconRequest();
+ request.setCallback(callback);
+
+ final IconTask task = new IconTask(
+ request,
+ Collections.<Preparer>emptyList(),
+ createListWithFailingLoaders(),
+ Collections.<Processor>emptyList(),
+ createGenerator());
+
+ final IconResponse response = task.call();
+
+ verify(callback).onIconResponse(response);
+ }
+
+ @Test
+ public void testTaskCancellationWhileLoading() {
+ // We simulate the cancellation by injecting a loader that interrupts the thread.
+ final IconLoader cancellingLoader = spy(new IconLoader() {
+ @Override
+ public IconResponse load(IconRequest request) {
+ Thread.currentThread().interrupt();
+ return null;
+ }
+ });
+
+ final List<Preparer> preparers = createListOfPreparers();
+ final List<Processor> processors = createListOfProcessors();
+
+ final List<IconLoader> loaders = Arrays.asList(
+ createFailingLoader(),
+ createFailingLoader(),
+ cancellingLoader,
+ createFailingLoader(),
+ createSuccessfulLoader(mock(Bitmap.class)));
+
+ final IconRequest request = createIconRequest();
+
+ final IconTask task = new IconTask(
+ request,
+ preparers,
+ loaders,
+ processors,
+ createGenerator());
+
+ final IconResponse response = task.call();
+ Assert.assertNull(response);
+
+ // Verify that all preparers are called
+ for (Preparer preparer : preparers) {
+ verify(preparer).prepare(request);
+ }
+
+ // Verify that first loaders are called
+ verify(loaders.get(0)).load(request);
+ verify(loaders.get(1)).load(request);
+
+ // Verify that our loader that interrupts the thread is called
+ verify(loaders.get(2)).load(request);
+
+ // Verify that all other loaders are not called
+ verify(loaders.get(3), never()).load(request);
+ verify(loaders.get(4), never()).load(request);
+
+ // Verify that no processors are called
+ for (Processor processor : processors) {
+ verify(processor, never()).process(eq(request), any(IconResponse.class));
+ }
+ }
+
+ @Test
+ public void testTaskCancellationWhileProcessing() {
+ final Processor cancellingProcessor = spy(new Processor() {
+ @Override
+ public void process(IconRequest request, IconResponse response) {
+ Thread.currentThread().interrupt();
+ }
+ });
+
+ final List<Preparer> preparers = createListOfPreparers();
+
+ final List<IconLoader> loaders = Arrays.asList(
+ createFailingLoader(),
+ createFailingLoader(),
+ createSuccessfulLoader(mock(Bitmap.class)));
+
+ final List<Processor> processors = Arrays.asList(
+ createProcessor(),
+ createProcessor(),
+ cancellingProcessor,
+ createProcessor(),
+ createProcessor());
+
+ final IconRequest request = createIconRequest();
+
+ final IconTask task = new IconTask(
+ request,
+ preparers,
+ loaders,
+ processors,
+ createGenerator());
+
+ final IconResponse response = task.call();
+ Assert.assertNull(response);
+
+ // Verify that all preparers are called
+ for (Preparer preparer : preparers) {
+ verify(preparer).prepare(request);
+ }
+
+ // Verify that all loaders are called
+ for (IconLoader loader : loaders) {
+ verify(loader).load(request);
+ }
+
+ // Verify that first processors are called
+ verify(processors.get(0)).process(eq(request), any(IconResponse.class));
+ verify(processors.get(1)).process(eq(request), any(IconResponse.class));
+
+ // Verify that cancelling processor is called
+ verify(processors.get(2)).process(eq(request), any(IconResponse.class));
+
+ // Verify that subsequent processors are not called
+ verify(processors.get(3), never()).process(eq(request), any(IconResponse.class));
+ verify(processors.get(4), never()).process(eq(request), any(IconResponse.class));
+ }
+
+ @Test
+ public void testTaskCancellationWhilePerparing() {
+ final Preparer failingPreparer = spy(new Preparer() {
+ @Override
+ public void prepare(IconRequest request) {
+ Thread.currentThread().interrupt();
+ }
+ });
+
+ final List<Preparer> preparers = Arrays.asList(
+ mock(Preparer.class),
+ mock(Preparer.class),
+ failingPreparer,
+ mock(Preparer.class),
+ mock(Preparer.class));
+
+ final List<IconLoader> loaders = createListWithSuccessfulLoader();
+ final List<Processor> processors = createListOfProcessors();
+
+ final IconRequest request = createIconRequest();
+
+ final IconTask task = new IconTask(
+ request,
+ preparers,
+ loaders,
+ processors,
+ createGenerator());
+
+ final IconResponse response = task.call();
+ Assert.assertNull(response);
+
+ // Verify that first preparers are called
+ verify(preparers.get(0)).prepare(request);
+ verify(preparers.get(1)).prepare(request);
+
+ // Verify that cancelling preparer is called
+ verify(preparers.get(2)).prepare(request);
+
+ // Verify that subsequent preparers are not called
+ verify(preparers.get(3), never()).prepare(request);
+ verify(preparers.get(4), never()).prepare(request);
+
+ // Verify that no loaders are called
+ for (IconLoader loader : loaders) {
+ verify(loader, never()).load(request);
+ }
+
+ // Verify that no processors are called
+ for (Processor processor : processors) {
+ verify(processor, never()).process(eq(request), any(IconResponse.class));
+ }
+ }
+
+ @Test
+ public void testNoLoadersOrProcessorsAreExecutedForPrepareOnlyTasks() {
+ final List<Preparer> preparers = createListOfPreparers();
+ final List<IconLoader> loaders = createListWithSuccessfulLoader();
+ final List<Processor> processors = createListOfProcessors();
+ final IconLoader generator = createGenerator();
+
+ final IconRequest request = createIconRequest()
+ .modify()
+ .prepareOnly()
+ .build();
+
+ final IconTask task = new IconTask(
+ request,
+ preparers,
+ loaders,
+ processors,
+ generator);
+
+ IconResponse response = task.call();
+
+ Assert.assertNull(response);
+
+ // Verify that all preparers are called
+ for (Preparer preparer : preparers) {
+ verify(preparer).prepare(request);
+ }
+
+ // Verify that no loaders are called
+ for (IconLoader loader : loaders) {
+ verify(loader, never()).load(request);
+ }
+
+ // Verify that no processors are called
+ for (Processor processor : processors) {
+ verify(processor, never()).process(eq(request), any(IconResponse.class));
+ }
+ }
+
+ public List<IconLoader> createListWithSuccessfulLoader() {
+ return Arrays.asList(
+ createFailingLoader(),
+ createFailingLoader(),
+ createSuccessfulLoader(mock(Bitmap.class)),
+ createFailingLoader());
+ }
+
+ public List<IconLoader> createListWithFailingLoaders() {
+ return Arrays.asList(
+ createFailingLoader(),
+ createFailingLoader(),
+ createFailingLoader(),
+ createFailingLoader(),
+ createFailingLoader());
+ }
+
+ public List<Preparer> createListOfPreparers() {
+ return Arrays.asList(
+ mock(Preparer.class),
+ mock(Preparer.class),
+ mock(Preparer.class),
+ mock(Preparer.class),
+ mock(Preparer.class));
+ }
+
+ public IconLoader createFailingLoader() {
+ final IconLoader loader = mock(IconLoader.class);
+ doReturn(null).when(loader).load(any(IconRequest.class));
+ return loader;
+ }
+
+ public IconLoader createSuccessfulLoader(Bitmap bitmap) {
+ IconResponse response = IconResponse.create(bitmap);
+
+ final IconLoader loader = mock(IconLoader.class);
+ doReturn(response).when(loader).load(any(IconRequest.class));
+ return loader;
+ }
+
+ public List<Processor> createListOfProcessors() {
+ return Arrays.asList(
+ mock(Processor.class),
+ mock(Processor.class),
+ mock(Processor.class),
+ mock(Processor.class),
+ mock(Processor.class));
+ }
+
+ public IconRequest createIconRequest() {
+ return Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createGenericIcon("http://www.mozilla.org/favicon.ico"))
+ .build();
+ }
+
+ public IconRequest createIconRequestWithoutUrls() {
+ return Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .build();
+ }
+
+ public IconLoader createGenerator() {
+ return createSuccessfulLoader(mock(Bitmap.class));
+ }
+
+ public Processor createProcessor() {
+ return mock(Processor.class);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconsHelper.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconsHelper.java
new file mode 100644
index 000000000..f40e2f629
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/TestIconsHelper.java
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons;
+
+import android.annotation.SuppressLint;
+
+import junit.framework.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.util.GeckoJarReader;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestIconsHelper {
+ @SuppressLint("AuthLeak") // Lint and Android Studio try to prevent developers from writing code
+ // with credentials in the URL (user:password@host). But in this case
+ // we explicitly want to do that, so we suppress the warnings.
+ @Test
+ public void testGuessDefaultFaviconURL() {
+ // Empty values
+
+ Assert.assertNull(IconsHelper.guessDefaultFaviconURL(null));
+ Assert.assertNull(IconsHelper.guessDefaultFaviconURL(""));
+ Assert.assertNull(IconsHelper.guessDefaultFaviconURL(" "));
+
+ // Special about: URLs.
+
+ Assert.assertEquals(
+ "about:home",
+ IconsHelper.guessDefaultFaviconURL("about:home"));
+
+ Assert.assertEquals(
+ "about:",
+ IconsHelper.guessDefaultFaviconURL("about:"));
+
+ Assert.assertEquals(
+ "about:addons",
+ IconsHelper.guessDefaultFaviconURL("about:addons"));
+
+ // Non http(s) URLS
+
+ final String jarUrl = GeckoJarReader.getJarURL(RuntimeEnvironment.application, "chrome/chrome/content/branding/favicon64.png");
+ Assert.assertEquals(jarUrl, IconsHelper.guessDefaultFaviconURL(jarUrl));
+
+ Assert.assertNull(IconsHelper.guessDefaultFaviconURL("content://some.random.provider/icons"));
+
+ Assert.assertNull(IconsHelper.guessDefaultFaviconURL("ftp://ftp.public.mozilla.org/this/is/made/up"));
+
+ Assert.assertNull(IconsHelper.guessDefaultFaviconURL("file:///"));
+
+ Assert.assertNull(IconsHelper.guessDefaultFaviconURL("file:///system/path"));
+
+ // Various http(s) URLs
+
+ Assert.assertEquals("http://www.mozilla.org/favicon.ico",
+ IconsHelper.guessDefaultFaviconURL("http://www.mozilla.org/"));
+
+ Assert.assertEquals("https://www.mozilla.org/favicon.ico",
+ IconsHelper.guessDefaultFaviconURL("https://www.mozilla.org/en-US/firefox/products/"));
+
+ Assert.assertEquals("https://example.org/favicon.ico",
+ IconsHelper.guessDefaultFaviconURL("https://example.org"));
+
+ Assert.assertEquals("http://user:password@example.org:9991/favicon.ico",
+ IconsHelper.guessDefaultFaviconURL("http://user:password@example.org:9991/status/760492829949001728"));
+
+ Assert.assertEquals("https://localhost:8888/favicon.ico",
+ IconsHelper.guessDefaultFaviconURL("https://localhost:8888/path/folder/file?some=query&params=none"));
+
+ Assert.assertEquals("http://192.168.0.1/favicon.ico",
+ IconsHelper.guessDefaultFaviconURL("http://192.168.0.1/local/action.cgi"));
+
+ Assert.assertEquals("https://medium.com/favicon.ico",
+ IconsHelper.guessDefaultFaviconURL("https://medium.com/firefox-mobile-engineering/firefox-for-android-hack-week-recap-f1ab12f5cc44#.rpmzz15ia"));
+
+ // Some broken, partial URLs
+
+ Assert.assertNull(IconsHelper.guessDefaultFaviconURL("http:"));
+ Assert.assertNull(IconsHelper.guessDefaultFaviconURL("http://"));
+ Assert.assertNull(IconsHelper.guessDefaultFaviconURL("https:/"));
+ }
+
+ @Test
+ public void testIsContainerType() {
+ // Empty values
+ Assert.assertFalse(IconsHelper.isContainerType(null));
+ Assert.assertFalse(IconsHelper.isContainerType(""));
+ Assert.assertFalse(IconsHelper.isContainerType(" "));
+
+ // Values that don't make any sense.
+ Assert.assertFalse(IconsHelper.isContainerType("Hello World"));
+ Assert.assertFalse(IconsHelper.isContainerType("no/no/no"));
+ Assert.assertFalse(IconsHelper.isContainerType("42"));
+
+ // Actual image MIME types that are not container types
+ Assert.assertFalse(IconsHelper.isContainerType("image/png"));
+ Assert.assertFalse(IconsHelper.isContainerType("application/bmp"));
+ Assert.assertFalse(IconsHelper.isContainerType("image/gif"));
+ Assert.assertFalse(IconsHelper.isContainerType("image/x-windows-bitmap"));
+ Assert.assertFalse(IconsHelper.isContainerType("image/jpeg"));
+ Assert.assertFalse(IconsHelper.isContainerType("application/x-png"));
+
+ // MIME types of image container
+ Assert.assertTrue(IconsHelper.isContainerType("image/vnd.microsoft.icon"));
+ Assert.assertTrue(IconsHelper.isContainerType("image/ico"));
+ Assert.assertTrue(IconsHelper.isContainerType("image/icon"));
+ Assert.assertTrue(IconsHelper.isContainerType("image/x-icon"));
+ Assert.assertTrue(IconsHelper.isContainerType("text/ico"));
+ Assert.assertTrue(IconsHelper.isContainerType("application/ico"));
+ }
+
+ @Test
+ public void testCanDecodeType() {
+ // Empty values
+ Assert.assertFalse(IconsHelper.canDecodeType(null));
+ Assert.assertFalse(IconsHelper.canDecodeType(""));
+ Assert.assertFalse(IconsHelper.canDecodeType(" "));
+
+ // Some things we can't decode (or that just aren't images)
+ Assert.assertFalse(IconsHelper.canDecodeType("image/svg+xml"));
+ Assert.assertFalse(IconsHelper.canDecodeType("video/avi"));
+ Assert.assertFalse(IconsHelper.canDecodeType("text/plain"));
+ Assert.assertFalse(IconsHelper.canDecodeType("image/x-quicktime"));
+ Assert.assertFalse(IconsHelper.canDecodeType("image/tiff"));
+ Assert.assertFalse(IconsHelper.canDecodeType("application/zip"));
+
+ // Some image MIME types we definitely can decode
+ Assert.assertTrue(IconsHelper.canDecodeType("image/bmp"));
+ Assert.assertTrue(IconsHelper.canDecodeType("image/x-icon"));
+ Assert.assertTrue(IconsHelper.canDecodeType("image/png"));
+ Assert.assertTrue(IconsHelper.canDecodeType("image/jpg"));
+ Assert.assertTrue(IconsHelper.canDecodeType("image/jpeg"));
+ Assert.assertTrue(IconsHelper.canDecodeType("image/ico"));
+ Assert.assertTrue(IconsHelper.canDecodeType("image/icon"));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestContentProviderLoader.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestContentProviderLoader.java
new file mode 100644
index 000000000..58bb3ddf9
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestContentProviderLoader.java
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestContentProviderLoader {
+ @Test
+ public void testNothingIsLoadedForHttpUrls() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createGenericIcon(
+ "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png"))
+ .build();
+
+ IconLoader loader = new ContentProviderLoader();
+ IconResponse response = loader.load(request);
+
+ Assert.assertNull(response);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDataUriLoader.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDataUriLoader.java
new file mode 100644
index 000000000..1fe6ad1a7
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDataUriLoader.java
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestDataUriLoader {
+ @Test
+ public void testNothingIsLoadedForHttpUrls() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createGenericIcon(
+ "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png"))
+ .build();
+
+ IconLoader loader = new DataUriLoader();
+ IconResponse response = loader.load(request);
+
+ Assert.assertNull(response);
+ }
+
+ @Test
+ public void testIconIsLoadedFromDataUri() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createGenericIcon(
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4AWP4z8AAxCDiP8N/AB3wBPxcBee7AAAAAElFTkSuQmCC"))
+ .build();
+
+ IconLoader loader = new DataUriLoader();
+ IconResponse response = loader.load(request);
+
+ Assert.assertNotNull(response);
+ Assert.assertNotNull(response.getBitmap());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDiskLoader.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDiskLoader.java
new file mode 100644
index 000000000..809c35102
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestDiskLoader.java
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.graphics.Bitmap;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.OutputStream;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestDiskLoader {
+ private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+ private static final String TEST_ICON_URL = "https://example.org/favicon.ico";
+
+ @Test
+ public void testLoadingFromEmptyCache() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .build();
+
+ final IconLoader loader = new DiskLoader();
+ final IconResponse response = loader.load(request);
+
+ Assert.assertNull(response);
+ }
+
+ @Test
+ public void testLoadingAfterAddingEntry() {
+ final Bitmap bitmap = createMockedBitmap();
+ final IconResponse originalResponse = IconResponse.createFromNetwork(bitmap, TEST_ICON_URL);
+
+ DiskStorage.get(RuntimeEnvironment.application)
+ .putIcon(originalResponse);
+
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .build();
+
+ final IconLoader loader = new DiskLoader();
+ final IconResponse loadedResponse = loader.load(request);
+
+ Assert.assertNotNull(loadedResponse);
+
+ // The responses are not the same: The original response was stored to disk and loaded from
+ // disk again. It's a copy effectively.
+ Assert.assertNotEquals(originalResponse, loadedResponse);
+ }
+
+ @Test
+ public void testNothingIsLoadedIfDiskShouldBeSkipped() {
+ final Bitmap bitmap = createMockedBitmap();
+ final IconResponse originalResponse = IconResponse.createFromNetwork(bitmap, TEST_ICON_URL);
+
+ DiskStorage.get(RuntimeEnvironment.application)
+ .putIcon(originalResponse);
+
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .skipDisk()
+ .build();
+
+ final IconLoader loader = new DiskLoader();
+ final IconResponse loadedResponse = loader.load(request);
+
+ Assert.assertNull(loadedResponse);
+ }
+
+ private Bitmap createMockedBitmap() {
+ final Bitmap bitmap = mock(Bitmap.class);
+
+ doReturn(true).when(bitmap).compress(any(Bitmap.CompressFormat.class), anyInt(), any(OutputStream.class));
+
+ return bitmap;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconDownloader.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconDownloader.java
new file mode 100644
index 000000000..533f14395
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconDownloader.java
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.content.Context;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.FailureCache;
+import org.robolectric.RuntimeEnvironment;
+
+import java.net.HttpURLConnection;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+@RunWith(TestRunner.class)
+public class TestIconDownloader {
+ /**
+ * Scenario: A request with a non HTTP URL (data:image/*) is executed.
+ *
+ * Verify that:
+ * * No download is performed.
+ */
+ @Test
+ public void testDownloaderDoesNothingForNonHttpUrls() throws Exception {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createGenericIcon(
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4AWP4z8AAxCDiP8N/AB3wBPxcBee7AAAAAElFTkSuQmCC"))
+ .build();
+
+ final IconDownloader downloader = spy(new IconDownloader());
+ IconResponse response = downloader.load(request);
+
+ Assert.assertNull(response);
+
+ verify(downloader, never()).downloadAndDecodeImage(any(Context.class), anyString());
+ verify(downloader, never()).connectTo(anyString());
+ }
+
+ /**
+ * Scenario: Request contains an URL and server returns 301 with location header (always the same URL).
+ *
+ * Verify that:
+ * * Download code stops and does not loop forever.
+ */
+ @Test
+ public void testRedirectsAreFollowedButNotInCircles() throws Exception {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createFavicon(
+ "https://www.mozilla.org/media/img/favicon.52506929be4c.ico",
+ 32,
+ "image/x-icon"))
+ .build();
+
+ HttpURLConnection mockedConnection = mock(HttpURLConnection.class);
+ doReturn(301).when(mockedConnection).getResponseCode();
+ doReturn("http://example.org/favicon.ico").when(mockedConnection).getHeaderField("Location");
+
+ final IconDownloader downloader = spy(new IconDownloader());
+ doReturn(mockedConnection).when(downloader).connectTo(anyString());
+ IconResponse response = downloader.load(request);
+
+ Assert.assertNull(response);
+
+ verify(downloader).connectTo("https://www.mozilla.org/media/img/favicon.52506929be4c.ico");
+ verify(downloader).connectTo("http://example.org/favicon.ico");
+ }
+
+ /**
+ * Scenario: Request contains an URL and server returns HTTP 404.
+ *
+ * Verify that:
+ * * URL is added to failure cache.
+ */
+ @Test
+ public void testUrlIsAddedToFailureCacheIfServerReturnsClientError() throws Exception {
+ final String faviconUrl = "https://www.mozilla.org/404.ico";
+
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createFavicon(faviconUrl, 32, "image/x-icon"))
+ .build();
+
+ HttpURLConnection mockedConnection = mock(HttpURLConnection.class);
+ doReturn(404).when(mockedConnection).getResponseCode();
+
+ Assert.assertFalse(FailureCache.get().isKnownFailure(faviconUrl));
+
+ final IconDownloader downloader = spy(new IconDownloader());
+ doReturn(mockedConnection).when(downloader).connectTo(anyString());
+ IconResponse response = downloader.load(request);
+
+ Assert.assertNull(response);
+
+ Assert.assertTrue(FailureCache.get().isKnownFailure(faviconUrl));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconGenerator.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconGenerator.java
new file mode 100644
index 000000000..70e341365
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestIconGenerator.java
@@ -0,0 +1,128 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.graphics.Bitmap;
+
+import org.junit.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestIconGenerator {
+ @Test
+ public void testNoIconIsGeneratorIfThereAreIconUrlsToLoadFrom() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createGenericIcon(
+ "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png"))
+ .icon(IconDescriptor.createGenericIcon(
+ "https://www.mozilla.org/media/img/favicon.52506929be4c.ico"))
+ .build();
+
+ IconLoader loader = new IconGenerator();
+ IconResponse response = loader.load(request);
+
+ Assert.assertNull(response);
+ }
+
+ @Test
+ public void testIconIsGeneratedForLastUrl() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createGenericIcon(
+ "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png"))
+ .build();
+
+ IconLoader loader = new IconGenerator();
+ IconResponse response = loader.load(request);
+
+ Assert.assertNotNull(response);
+ Assert.assertNotNull(response.getBitmap());
+ }
+
+ @Test
+ public void testRepresentativeCharacter() {
+ Assert.assertEquals("M", IconGenerator.getRepresentativeCharacter("https://mozilla.org"));
+ Assert.assertEquals("W", IconGenerator.getRepresentativeCharacter("http://wikipedia.org"));
+ Assert.assertEquals("P", IconGenerator.getRepresentativeCharacter("http://plus.google.com"));
+ Assert.assertEquals("E", IconGenerator.getRepresentativeCharacter("https://en.m.wikipedia.org/wiki/Main_Page"));
+
+ // Stripping common prefixes
+ Assert.assertEquals("T", IconGenerator.getRepresentativeCharacter("http://www.theverge.com"));
+ Assert.assertEquals("F", IconGenerator.getRepresentativeCharacter("https://m.facebook.com"));
+ Assert.assertEquals("T", IconGenerator.getRepresentativeCharacter("https://mobile.twitter.com"));
+
+ // Special urls
+ Assert.assertEquals("?", IconGenerator.getRepresentativeCharacter("file:///"));
+ Assert.assertEquals("S", IconGenerator.getRepresentativeCharacter("file:///system/"));
+ Assert.assertEquals("P", IconGenerator.getRepresentativeCharacter("ftp://people.mozilla.org/test"));
+
+ // No values
+ Assert.assertEquals("?", IconGenerator.getRepresentativeCharacter(""));
+ Assert.assertEquals("?", IconGenerator.getRepresentativeCharacter(null));
+
+ // Rubbish
+ Assert.assertEquals("Z", IconGenerator.getRepresentativeCharacter("zZz"));
+ Assert.assertEquals("Ö", IconGenerator.getRepresentativeCharacter("ölkfdpou3rkjaslfdköasdfo8"));
+ Assert.assertEquals("?", IconGenerator.getRepresentativeCharacter("_*+*'##"));
+ Assert.assertEquals("ツ", IconGenerator.getRepresentativeCharacter("¯\\_(ツ)_/¯"));
+ Assert.assertEquals("ಠ", IconGenerator.getRepresentativeCharacter("ಠ_ಠ Look of Disapproval"));
+
+ // Non-ASCII
+ Assert.assertEquals("Ä", IconGenerator.getRepresentativeCharacter("http://www.ätzend.de"));
+ Assert.assertEquals("名", IconGenerator.getRepresentativeCharacter("http://名がドメイン.com"));
+ Assert.assertEquals("C", IconGenerator.getRepresentativeCharacter("http://√.com"));
+ Assert.assertEquals("ß", IconGenerator.getRepresentativeCharacter("http://ß.de"));
+ Assert.assertEquals("Ԛ", IconGenerator.getRepresentativeCharacter("http://ԛәлп.com/")); // cyrillic
+
+ // Punycode
+ Assert.assertEquals("X", IconGenerator.getRepresentativeCharacter("http://xn--tzend-fra.de")); // ätzend.de
+ Assert.assertEquals("X", IconGenerator.getRepresentativeCharacter("http://xn--V8jxj3d1dzdz08w.com")); // 名がドメイン.com
+
+ // Numbers
+ Assert.assertEquals("1", IconGenerator.getRepresentativeCharacter("https://www.1and1.com/"));
+
+ // IP
+ Assert.assertEquals("1", IconGenerator.getRepresentativeCharacter("https://192.168.0.1"));
+ }
+
+ @Test
+ public void testPickColor() {
+ final int color = IconGenerator.pickColor("http://m.facebook.com");
+
+ // Color does not change
+ for (int i = 0; i < 100; i++) {
+ Assert.assertEquals(color, IconGenerator.pickColor("http://m.facebook.com"));
+ }
+
+ // Color is stable for "similar" hosts.
+ Assert.assertEquals(color, IconGenerator.pickColor("https://m.facebook.com"));
+ Assert.assertEquals(color, IconGenerator.pickColor("http://facebook.com"));
+ Assert.assertEquals(color, IconGenerator.pickColor("http://www.facebook.com"));
+ Assert.assertEquals(color, IconGenerator.pickColor("http://www.facebook.com/foo/bar/foobar?mobile=1"));
+ }
+
+ @Test
+ public void testGeneratingFavicon() {
+ final IconResponse response = IconGenerator.generate(RuntimeEnvironment.application, "http://m.facebook.com");
+ final Bitmap bitmap = response.getBitmap();
+
+ Assert.assertNotNull(bitmap);
+
+ final int size = RuntimeEnvironment.application.getResources().getDimensionPixelSize(R.dimen.favicon_bg);
+ Assert.assertEquals(size, bitmap.getWidth());
+ Assert.assertEquals(size, bitmap.getHeight());
+
+ Assert.assertEquals(Bitmap.Config.ARGB_8888, bitmap.getConfig());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestJarLoader.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestJarLoader.java
new file mode 100644
index 000000000..48f0c26eb
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestJarLoader.java
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestJarLoader {
+ @Test
+ public void testNothingIsLoadedForHttpUrls() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createGenericIcon(
+ "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png"))
+ .build();
+
+ IconLoader loader = new JarLoader();
+ IconResponse response = loader.load(request);
+
+ Assert.assertNull(response);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestLegacyLoader.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestLegacyLoader.java
new file mode 100644
index 000000000..eecf76788
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestLegacyLoader.java
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.graphics.Bitmap;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.background.db.DelegatingTestContentProvider;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.BrowserProvider;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.shadows.ShadowContentResolver;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.Iterator;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+@RunWith(TestRunner.class)
+public class TestLegacyLoader {
+ private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+ private static final String TEST_ICON_URL = "https://example.org/favicon.ico";
+ private static final String TEST_ICON_URL_2 = "https://example.com/page/favicon.ico";
+ private static final String TEST_ICON_URL_3 = "https://example.net/icon/favicon.ico";
+
+ @Test
+ public void testDatabaseIsQueriesForNormalRequestsWithNetworkSkipped() {
+ // We're going to query BrowserProvider via LegacyLoader, and will access a database.
+ // We need to ensure we close our db connection properly.
+ // This is the only test in this class that actually accesses a database. If that changes,
+ // move BrowserProvider registration into a @Before method, and provider.shutdown into @After.
+ final BrowserProvider provider = new BrowserProvider();
+ provider.onCreate();
+ ShadowContentResolver.registerProvider(BrowserContract.AUTHORITY, new DelegatingTestContentProvider(provider));
+ try {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .skipNetwork()
+ .build();
+
+ final LegacyLoader loader = spy(new LegacyLoader());
+ final IconResponse response = loader.load(request);
+
+ verify(loader).loadBitmapFromDatabase(request);
+ Assert.assertNull(response);
+ // Close any open db connections.
+ } finally {
+ provider.shutdown();
+ }
+ }
+
+ @Test
+ public void testNothingIsLoadedIfNetworkIsNotSkipped() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .build();
+
+ final LegacyLoader loader = spy(new LegacyLoader());
+ final IconResponse response = loader.load(request);
+
+ verify(loader, never()).loadBitmapFromDatabase(request);
+
+ Assert.assertNull(response);
+ }
+
+ @Test
+ public void testNothingIsLoadedIfDiskSHouldBeSkipped() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .skipDisk()
+ .build();
+
+ final LegacyLoader loader = spy(new LegacyLoader());
+ final IconResponse response = loader.load(request);
+
+ verify(loader, never()).loadBitmapFromDatabase(request);
+
+ Assert.assertNull(response);
+ }
+
+ @Test
+ public void testLoadedBitmapIsReturnedAsResponse() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .skipNetwork()
+ .build();
+
+ final Bitmap bitmap = mock(Bitmap.class);
+
+ final LegacyLoader loader = spy(new LegacyLoader());
+ doReturn(bitmap).when(loader).loadBitmapFromDatabase(request);
+
+ final IconResponse response = loader.load(request);
+
+ Assert.assertNotNull(response);
+ Assert.assertEquals(bitmap, response.getBitmap());
+ }
+
+ @Test
+ public void testLoaderOnlyLoadsIfThereIsOneIconLeft() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_2))
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_3))
+ .skipNetwork()
+ .build();
+
+ final LegacyLoader loader = spy(new LegacyLoader());
+ doReturn(mock(Bitmap.class)).when(loader).loadBitmapFromDatabase(request);
+
+ // First load doesn't load an icon.
+ Assert.assertNull(loader.load(request));
+
+ // Second load doesn't load an icon.
+ removeFirstIcon(request);
+ Assert.assertNull(loader.load(request));
+
+ // Now only one icon is left and a response will be returned.
+ removeFirstIcon(request);
+ Assert.assertNotNull(loader.load(request));
+ }
+
+ private void removeFirstIcon(IconRequest request) {
+ final Iterator<IconDescriptor> iterator = request.getIconIterator();
+ if (iterator.hasNext()) {
+ iterator.next();
+ iterator.remove();
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestMemoryLoader.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestMemoryLoader.java
new file mode 100644
index 000000000..414ac8cc7
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/loader/TestMemoryLoader.java
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import junit.framework.Assert;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+import org.robolectric.RuntimeEnvironment;
+
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestMemoryLoader {
+ private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+ private static final String TEST_ICON_URL = "https://example.org/favicon.ico";
+
+ @Before
+ public void setUp() {
+ // Make sure to start with an empty memory cache.
+ MemoryStorage.get().evictAll();
+ }
+
+ @Test
+ public void testStoringAndLoadingFromMemory() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .build();
+
+ final IconLoader loader = new MemoryLoader();
+
+ Assert.assertNull(loader.load(request));
+
+ final Bitmap bitmap = mock(Bitmap.class);
+ final IconResponse response = IconResponse.create(bitmap);
+ response.updateColor(Color.MAGENTA);
+
+ MemoryStorage.get().putIcon(TEST_ICON_URL, response);
+
+ final IconResponse loadedResponse = loader.load(request);
+
+ Assert.assertNotNull(loadedResponse);
+ Assert.assertEquals(bitmap, loadedResponse.getBitmap());
+ Assert.assertEquals(Color.MAGENTA, loadedResponse.getColor());
+ }
+
+ @Test
+ public void testNothingIsLoadedIfMemoryShouldBeSkipped() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .skipMemory()
+ .build();
+
+ final IconLoader loader = new MemoryLoader();
+
+ Assert.assertNull(loader.load(request));
+
+ final Bitmap bitmap = mock(Bitmap.class);
+ final IconResponse response = IconResponse.create(bitmap);
+
+ MemoryStorage.get().putIcon(TEST_ICON_URL, response);
+
+ Assert.assertNull(loader.load(request));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAboutPagesPreparer.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAboutPagesPreparer.java
new file mode 100644
index 000000000..f0d4cb7e2
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAboutPagesPreparer.java
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+package org.mozilla.gecko.icons.preparation;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestAboutPagesPreparer {
+ private static final String[] ABOUT_PAGES = {
+ AboutPages.ACCOUNTS,
+ AboutPages.ADDONS,
+ AboutPages.CONFIG,
+ AboutPages.DOWNLOADS,
+ AboutPages.FIREFOX,
+ AboutPages.HEALTHREPORT,
+ AboutPages.HOME,
+ AboutPages.UPDATER
+ };
+
+ @Test
+ public void testPreparerAddsUrlsForAllAboutPages() {
+ final Preparer preparer = new AboutPagesPreparer();
+
+ for (String url : ABOUT_PAGES) {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(url)
+ .build();
+
+ Assert.assertEquals(0, request.getIconCount());
+
+ preparer.prepare(request);
+
+ Assert.assertEquals("Added icon URL for URL: " + url, 1, request.getIconCount());
+ }
+ }
+
+ @Test
+ public void testPrepareDoesNotAddUrlForGenericHttpUrl() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .build();
+
+ Assert.assertEquals(0, request.getIconCount());
+
+ final Preparer preparer = new AboutPagesPreparer();
+ preparer.prepare(request);
+
+ Assert.assertEquals(0, request.getIconCount());
+ }
+
+ @Test
+ public void testAddedUrlHasJarScheme() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(AboutPages.DOWNLOADS)
+ .build();
+
+ final Preparer preparer = new AboutPagesPreparer();
+ preparer.prepare(request);
+
+ Assert.assertEquals(1, request.getIconCount());
+
+ final String url = request.getBestIcon().getUrl();
+ Assert.assertNotNull(url);
+ Assert.assertTrue(url.startsWith("jar:jar:"));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAddDefaultIconUrl.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAddDefaultIconUrl.java
new file mode 100644
index 000000000..ce5e82d0b
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestAddDefaultIconUrl.java
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.preparation;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.Iterator;
+
+@RunWith(TestRunner.class)
+public class TestAddDefaultIconUrl {
+ @Test
+ public void testAddingDefaultUrl() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createTouchicon(
+ "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png",
+ 180,
+ "image/png"))
+ .icon(IconDescriptor.createFavicon(
+ "https://www.mozilla.org/media/img/favicon.52506929be4c.ico",
+ 32,
+ "image/x-icon"))
+ .icon(IconDescriptor.createFavicon(
+ "jar:jar:wtf.png",
+ 16,
+ "image/png"))
+ .build();
+
+
+ Assert.assertEquals(3, request.getIconCount());
+ Assert.assertFalse(containsUrl(request, "http://www.mozilla.org/favicon.ico"));
+
+ Preparer preparer = new AddDefaultIconUrl();
+ preparer.prepare(request);
+
+ Assert.assertEquals(4, request.getIconCount());
+ Assert.assertTrue(containsUrl(request, "http://www.mozilla.org/favicon.ico"));
+ }
+
+ @Test
+ public void testDefaultUrlIsNotAddedIfItAlreadyExists() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl("http://www.mozilla.org")
+ .icon(IconDescriptor.createFavicon(
+ "http://www.mozilla.org/favicon.ico",
+ 32,
+ "image/x-icon"))
+ .build();
+
+ Assert.assertEquals(1, request.getIconCount());
+
+ Preparer preparer = new AddDefaultIconUrl();
+ preparer.prepare(request);
+
+ Assert.assertEquals(1, request.getIconCount());
+ }
+
+ private boolean containsUrl(IconRequest request, String url) {
+ final Iterator<IconDescriptor> iterator = request.getIconIterator();
+
+ while (iterator.hasNext()) {
+ IconDescriptor descriptor = iterator.next();
+
+ if (descriptor.getUrl().equals(url)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterKnownFailureUrls.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterKnownFailureUrls.java
new file mode 100644
index 000000000..67584c4cf
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterKnownFailureUrls.java
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.preparation;
+
+import junit.framework.Assert;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.FailureCache;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestFilterKnownFailureUrls {
+ private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+ private static final String TEST_ICON_URL = "https://example.org/favicon.ico";
+
+ @Before
+ public void setUp() {
+ // Make sure we always start with an empty cache.
+ FailureCache.get().evictAll();
+ }
+
+ @Test
+ public void testFilterDoesNothingByDefault() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .build();
+
+ Assert.assertEquals(1, request.getIconCount());
+
+ final Preparer preparer = new FilterKnownFailureUrls();
+ preparer.prepare(request);
+
+ Assert.assertEquals(1, request.getIconCount());
+ }
+
+ @Test
+ public void testFilterKnownFailureUrls() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .build();
+
+ Assert.assertEquals(1, request.getIconCount());
+
+ FailureCache.get().rememberFailure(TEST_ICON_URL);
+
+ final Preparer preparer = new FilterKnownFailureUrls();
+ preparer.prepare(request);
+
+ Assert.assertEquals(0, request.getIconCount());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterMimeTypes.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterMimeTypes.java
new file mode 100644
index 000000000..e8339b4e9
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterMimeTypes.java
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.preparation;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestFilterMimeTypes {
+ private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+ private static final String TEST_ICON_URL = "https://example.org/favicon.ico";
+ private static final String TEST_ICON_URL_2 = "https://mozilla.org/favicon.ico";
+
+ @Test
+ public void testUrlsWithoutMimeTypesAreNotFiltered() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL))
+ .build();
+
+ Assert.assertEquals(1, request.getIconCount());
+
+ final Preparer preparer = new FilterMimeTypes();
+ preparer.prepare(request);
+
+ Assert.assertEquals(1, request.getIconCount());
+ }
+
+ @Test
+ public void testUnknownMimeTypesAreFiltered() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createFavicon(TEST_ICON_URL, 256, "image/zaphod"))
+ .icon(IconDescriptor.createFavicon(TEST_ICON_URL_2, 128, "audio/mpeg"))
+ .build();
+
+ Assert.assertEquals(2, request.getIconCount());
+
+ final Preparer preparer = new FilterMimeTypes();
+ preparer.prepare(request);
+
+ Assert.assertEquals(0, request.getIconCount());
+ }
+
+ @Test
+ public void testKnownMimeTypesAreNotFiltered() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createFavicon(TEST_ICON_URL, 256, "image/x-icon"))
+ .icon(IconDescriptor.createFavicon(TEST_ICON_URL_2, 128, "image/png"))
+ .build();
+
+ Assert.assertEquals(2, request.getIconCount());
+
+ final Preparer preparer = new FilterMimeTypes();
+ preparer.prepare(request);
+
+ Assert.assertEquals(2, request.getIconCount());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterPrivilegedUrls.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterPrivilegedUrls.java
new file mode 100644
index 000000000..53fcbd05a
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestFilterPrivilegedUrls.java
@@ -0,0 +1,86 @@
+package org.mozilla.gecko.icons.preparation;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.Iterator;
+
+@RunWith(TestRunner.class)
+public class TestFilterPrivilegedUrls {
+ private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+
+ private static final String TEST_ICON_HTTP_URL = "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png";
+ private static final String TEST_ICON_HTTP_URL_2 = "https://www.mozilla.org/media/img/favicon.52506929be4c.ico";
+ private static final String TEST_ICON_JAR_URL = "jar:jar:wtf.png";
+
+ @Test
+ public void testFiltering() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL))
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL_2))
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_JAR_URL))
+ .build();
+
+ Assert.assertEquals(3, request.getIconCount());
+
+ Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL));
+ Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2));
+ Assert.assertTrue(containsUrl(request, TEST_ICON_JAR_URL));
+
+ Preparer preparer = new FilterPrivilegedUrls();
+ preparer.prepare(request);
+
+ Assert.assertEquals(2, request.getIconCount());
+
+ Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL));
+ Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2));
+ Assert.assertFalse(containsUrl(request, TEST_ICON_JAR_URL));
+ }
+
+ @Test
+ public void testNothingIsFilteredForPrivilegedRequests() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL))
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL_2))
+ .icon(IconDescriptor.createGenericIcon(TEST_ICON_JAR_URL))
+ .privileged(true)
+ .build();
+
+ Assert.assertEquals(3, request.getIconCount());
+
+ Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL));
+ Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2));
+ Assert.assertTrue(containsUrl(request, TEST_ICON_JAR_URL));
+
+ Preparer preparer = new FilterPrivilegedUrls();
+ preparer.prepare(request);
+
+ Assert.assertEquals(3, request.getIconCount());
+
+ Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL));
+ Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2));
+ Assert.assertTrue(containsUrl(request, TEST_ICON_JAR_URL));
+ }
+
+ private boolean containsUrl(IconRequest request, String url) {
+ final Iterator<IconDescriptor> iterator = request.getIconIterator();
+
+ while (iterator.hasNext()) {
+ IconDescriptor descriptor = iterator.next();
+
+ if (descriptor.getUrl().equals(url)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestLookupIconUrl.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestLookupIconUrl.java
new file mode 100644
index 000000000..99bac076b
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/preparation/TestLookupIconUrl.java
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.preparation;
+
+import junit.framework.Assert;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestLookupIconUrl {
+ private static final String TEST_PAGE_URL = "http://www.mozilla.org";
+
+ private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico";
+ private static final String TEST_ICON_URL_2 = "http://example.org/favicon.ico";
+ private static final String TEST_ICON_URL_3 = "http://example.com/favicon.ico";
+ private static final String TEST_ICON_URL_4 = "http://example.net/favicon.ico";
+
+
+ @Before
+ public void setUp() {
+ MemoryStorage.get().evictAll();
+ }
+
+ @Test
+ public void testNoIconUrlIsAddedByDefault() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .build();
+
+ Assert.assertEquals(0, request.getIconCount());
+
+ Preparer preparer = new LookupIconUrl();
+ preparer.prepare(request);
+
+ Assert.assertEquals(0, request.getIconCount());
+ }
+
+ @Test
+ public void testIconUrlIsAddedFromMemory() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .build();
+
+ MemoryStorage.get().putMapping(request, TEST_ICON_URL_1);
+
+ Assert.assertEquals(0, request.getIconCount());
+
+ Preparer preparer = new LookupIconUrl();
+ preparer.prepare(request);
+
+ Assert.assertEquals(1, request.getIconCount());
+
+ Assert.assertEquals(TEST_ICON_URL_1, request.getBestIcon().getUrl());
+ }
+
+ @Test
+ public void testIconUrlIsAddedFromDisk() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .build();
+
+ DiskStorage.get(RuntimeEnvironment.application).putMapping(request, TEST_ICON_URL_2);
+
+ Assert.assertEquals(0, request.getIconCount());
+
+ Preparer preparer = new LookupIconUrl();
+ preparer.prepare(request);
+
+ Assert.assertEquals(1, request.getIconCount());
+
+ Assert.assertEquals(TEST_ICON_URL_2, request.getBestIcon().getUrl());
+ }
+
+ @Test
+ public void testIconUrlIsAddedFromMemoryBeforeUsingDiskStorage() {
+ final IconRequest request = Icons.with(RuntimeEnvironment.application)
+ .pageUrl(TEST_PAGE_URL)
+ .build();
+
+ MemoryStorage.get().putMapping(request, TEST_ICON_URL_3);
+ DiskStorage.get(RuntimeEnvironment.application).putMapping(request, TEST_ICON_URL_4);
+
+ Assert.assertEquals(0, request.getIconCount());
+
+ Preparer preparer = new LookupIconUrl();
+ preparer.prepare(request);
+
+ Assert.assertEquals(1, request.getIconCount());
+
+ Assert.assertEquals(TEST_ICON_URL_3, request.getBestIcon().getUrl());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestColorProcessor.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestColorProcessor.java
new file mode 100644
index 000000000..6057c0776
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestColorProcessor.java
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.processing;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconResponse;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestColorProcessor {
+ @Test
+ public void testExtractingColor() {
+ final IconResponse response = IconResponse.create(createRedBitmapMock());
+
+ Assert.assertFalse(response.hasColor());
+ Assert.assertEquals(0, response.getColor());
+
+ final Processor processor = new ColorProcessor();
+ processor.process(null, response);
+
+ Assert.assertTrue(response.hasColor());
+ Assert.assertEquals(Color.RED, response.getColor());
+ }
+
+ private Bitmap createRedBitmapMock() {
+ final Bitmap bitmap = mock(Bitmap.class);
+
+ doReturn(1).when(bitmap).getWidth();
+ doReturn(1).when(bitmap).getHeight();
+
+ doAnswer(new Answer<Void>() {
+ @Override
+ public Void answer(InvocationOnMock invocation) throws Throwable {
+ Object[] args = invocation.getArguments();
+ int[] pixels = (int[]) args[0];
+ for (int i = 0; i < pixels.length; i++) {
+ pixels[i] = Color.RED;
+ }
+ return null;
+ }
+ }).when(bitmap).getPixels(any(int[].class), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
+
+ return bitmap;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestDiskProcessor.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestDiskProcessor.java
new file mode 100644
index 000000000..eea5c9bf6
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestDiskProcessor.java
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.processing;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import junit.framework.Assert;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.OutputStream;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestDiskProcessor {
+ private static final String PAGE_URL = "https://www.mozilla.org";
+ private static final String ICON_URL = "https://www.mozilla.org/favicon.ico";
+
+ @Test
+ public void testNetworkResponseIsStoredInCache() {
+ final IconRequest request = createTestRequest();
+ final IconResponse response = createTestNetworkResponse();
+
+ final DiskStorage storage = DiskStorage.get(RuntimeEnvironment.application);
+ Assert.assertNull(storage.getIcon(ICON_URL));
+
+ final Processor processor = new DiskProcessor();
+ processor.process(request, response);
+
+ Assert.assertNotNull(storage.getIcon(ICON_URL));
+ }
+
+ @Test
+ public void testGeneratedResponseIsNotStored() {
+ final IconRequest request = createTestRequest();
+ final IconResponse response = createGeneratedResponse();
+
+ final DiskStorage storage = DiskStorage.get(RuntimeEnvironment.application);
+ Assert.assertNull(storage.getIcon(ICON_URL));
+
+ final Processor processor = new DiskProcessor();
+ processor.process(request, response);
+
+ Assert.assertNull(storage.getIcon(ICON_URL));
+ }
+
+ @Test
+ public void testNothingIsStoredIfDiskShouldBeSkipped() {
+ final IconRequest request = createTestRequest()
+ .modify()
+ .skipDisk()
+ .build();
+ final IconResponse response = createTestNetworkResponse();
+
+ final DiskStorage storage = DiskStorage.get(RuntimeEnvironment.application);
+ Assert.assertNull(storage.getIcon(ICON_URL));
+
+ final Processor processor = new DiskProcessor();
+ processor.process(request, response);
+
+ Assert.assertNull(storage.getIcon(ICON_URL));
+ }
+
+ private IconRequest createTestRequest() {
+ return Icons.with(RuntimeEnvironment.application)
+ .pageUrl(PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(ICON_URL))
+ .build();
+ }
+
+ public IconResponse createTestNetworkResponse() {
+ return IconResponse.createFromNetwork(createMockedBitmap(), ICON_URL);
+ }
+
+ public IconResponse createGeneratedResponse() {
+ return IconResponse.createGenerated(createMockedBitmap(), Color.WHITE);
+ }
+
+ private Bitmap createMockedBitmap() {
+ final Bitmap bitmap = mock(Bitmap.class);
+
+ doReturn(true).when(bitmap).compress(any(Bitmap.CompressFormat.class), anyInt(), any(OutputStream.class));
+
+ return bitmap;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestMemoryProcessor.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestMemoryProcessor.java
new file mode 100644
index 000000000..fbc1e0baf
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestMemoryProcessor.java
@@ -0,0 +1,134 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.processing;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+import org.robolectric.RuntimeEnvironment;
+
+import static org.mockito.Mockito.mock;
+
+@RunWith(TestRunner.class)
+public class TestMemoryProcessor {
+ private static final String PAGE_URL = "https://www.mozilla.org";
+ private static final String ICON_URL = "https://www.mozilla.org/favicon.ico";
+ private static final String DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4AWP4z8AAxCDiP8N/AB3wBPxcBee7AAAAAElFTkSuQmCC";
+
+ @Before
+ public void setUp() {
+ MemoryStorage.get().evictAll();
+ }
+
+ @Test
+ public void testResponsesAreStoredInMemory() {
+ final IconRequest request = createTestRequest();
+
+ Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+ Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+
+ final Processor processor = new MemoryProcessor();
+ processor.process(request, createTestResponse());
+
+ Assert.assertNotNull(MemoryStorage.get().getIcon(ICON_URL));
+ Assert.assertNotNull(MemoryStorage.get().getMapping(PAGE_URL));
+ }
+
+ @Test
+ public void testNothingIsStoredIfMemoryShouldBeSkipped() {
+ final IconRequest request = createTestRequest()
+ .modify()
+ .skipMemory()
+ .build();
+
+ Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+ Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+
+ final Processor processor = new MemoryProcessor();
+ processor.process(request, createTestResponse());
+
+ Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+ Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+ }
+
+ @Test
+ public void testNothingIsStoredForRequestsWithoutUrl() {
+ final IconRequest request = createTestRequestWithoutIconUrl();
+
+ Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+ Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+
+ final Processor processor = new MemoryProcessor();
+ processor.process(request, createTestResponse());
+
+ Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+ Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+ }
+
+ @Test
+ public void testNothingIsStoredForGeneratedResponses() {
+ final IconRequest request = createTestRequest();
+
+ Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+ Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+
+ final Processor processor = new MemoryProcessor();
+ processor.process(request, createGeneratedTestResponse());
+
+ Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL));
+ Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+ }
+
+ @Test
+ public void testNothingIsStoredForDataUris() {
+ final IconRequest request = createDataUriTestRequest();
+
+ Assert.assertNull(MemoryStorage.get().getIcon(DATA_URL));
+ Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+
+ final Processor processor = new MemoryProcessor();
+ processor.process(request, createTestResponse());
+
+ Assert.assertNull(MemoryStorage.get().getIcon(DATA_URL));
+ Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL));
+ }
+
+ private IconRequest createTestRequest() {
+ return Icons.with(RuntimeEnvironment.application)
+ .pageUrl(PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(ICON_URL))
+ .build();
+ }
+
+ private IconRequest createTestRequestWithoutIconUrl() {
+ return Icons.with(RuntimeEnvironment.application)
+ .pageUrl(PAGE_URL)
+ .build();
+ }
+
+ private IconRequest createDataUriTestRequest() {
+ return Icons.with(RuntimeEnvironment.application)
+ .pageUrl(PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(DATA_URL))
+ .build();
+ }
+
+ private IconResponse createTestResponse() {
+ return IconResponse.create(mock(Bitmap.class));
+ }
+
+ private IconResponse createGeneratedTestResponse() {
+ return IconResponse.createGenerated(mock(Bitmap.class), Color.GREEN);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestResizingProcessor.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestResizingProcessor.java
new file mode 100644
index 000000000..dbcb4e2ee
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/icons/processing/TestResizingProcessor.java
@@ -0,0 +1,111 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.icons.processing;
+
+import android.graphics.Bitmap;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.robolectric.RuntimeEnvironment;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+@RunWith(TestRunner.class)
+public class TestResizingProcessor {
+ private static final String PAGE_URL = "https://www.mozilla.org";
+ private static final String ICON_URL = "https://www.mozilla.org/favicon.ico";
+
+ @Test
+ public void testBitmapIsNotResizedIfItAlreadyHasTheTargetSize() {
+ final IconRequest request = createTestRequest();
+
+ final Bitmap bitmap = createBitmapMock(request.getTargetSize());
+ final IconResponse response = spy(IconResponse.create(bitmap));
+
+ final ResizingProcessor processor = spy(new ResizingProcessor());
+ processor.process(request, response);
+
+ verify(processor, never()).resize(any(Bitmap.class), anyInt());
+ verify(bitmap, never()).recycle();
+ verify(response, never()).updateBitmap(any(Bitmap.class));
+ }
+
+ @Test
+ public void testLargerBitmapsAreResized() {
+ final IconRequest request = createTestRequest();
+
+ final Bitmap bitmap = createBitmapMock(request.getTargetSize() * 2);
+ final IconResponse response = spy(IconResponse.create(bitmap));
+
+ final ResizingProcessor processor = spy(new ResizingProcessor());
+ final Bitmap resizedBitmap = mock(Bitmap.class);
+ doReturn(resizedBitmap).when(processor).resize(any(Bitmap.class), anyInt());
+ processor.process(request, response);
+
+ verify(processor).resize(bitmap, request.getTargetSize());
+ verify(bitmap).recycle();
+ verify(response).updateBitmap(resizedBitmap);
+ }
+
+ @Test
+ public void testBitmapIsUpscaledToTargetSize() {
+ final IconRequest request = createTestRequest();
+
+ final Bitmap bitmap = createBitmapMock(request.getTargetSize() / 2 + 1);
+ final IconResponse response = spy(IconResponse.create(bitmap));
+
+ final ResizingProcessor processor = spy(new ResizingProcessor());
+ final Bitmap resizedBitmap = mock(Bitmap.class);
+ doReturn(resizedBitmap).when(processor).resize(any(Bitmap.class), anyInt());
+ processor.process(request, response);
+
+ verify(processor).resize(bitmap, request.getTargetSize());
+ verify(bitmap).recycle();
+ verify(response).updateBitmap(resizedBitmap);
+ }
+
+ @Test
+ public void testBitmapIsNotScaledMoreThanTwoTimesTheSize() {
+ final IconRequest request = createTestRequest();
+
+ final Bitmap bitmap = createBitmapMock(5);
+ final IconResponse response = spy(IconResponse.create(bitmap));
+
+ final ResizingProcessor processor = spy(new ResizingProcessor());
+ final Bitmap resizedBitmap = mock(Bitmap.class);
+ doReturn(resizedBitmap).when(processor).resize(any(Bitmap.class), anyInt());
+ processor.process(request, response);
+
+ verify(processor).resize(bitmap, 10);
+ verify(bitmap).recycle();
+ verify(response).updateBitmap(resizedBitmap);
+ }
+
+ private IconRequest createTestRequest() {
+ return Icons.with(RuntimeEnvironment.application)
+ .pageUrl(PAGE_URL)
+ .icon(IconDescriptor.createGenericIcon(ICON_URL))
+ .build();
+ }
+
+ private Bitmap createBitmapMock(int size) {
+ final Bitmap bitmap = mock(Bitmap.class);
+
+ doReturn(size).when(bitmap).getWidth();
+ doReturn(size).when(bitmap).getHeight();
+
+ return bitmap;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/permissions/TestPermissions.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/permissions/TestPermissions.java
new file mode 100644
index 000000000..07fbab493
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/permissions/TestPermissions.java
@@ -0,0 +1,253 @@
+/* -*- 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.permissions;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Context;
+import android.content.pm.PackageManager;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Matchers;
+
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import static org.mockito.Mockito.*;
+
+@RunWith(TestRunner.class)
+public class TestPermissions {
+ @Test
+ public void testSuccessRunnableIsExecutedIfPermissionsAreGranted() {
+ Permissions.setPermissionHelper(mockGrantingHelper());
+
+ Runnable onPermissionsGranted = mock(Runnable.class);
+ Runnable onPermissionsDenied = mock(Runnable.class);
+
+ Permissions.from(mockActivity())
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .andFallback(onPermissionsDenied)
+ .run(onPermissionsGranted);
+
+ verify(onPermissionsDenied, never()).run();
+ verify(onPermissionsGranted).run();
+ }
+
+ @Test
+ public void testFallbackRunnableIsExecutedIfPermissionsAreDenied() {
+ Permissions.setPermissionHelper(mockDenyingHelper());
+
+ Runnable onPermissionsGranted = mock(Runnable.class);
+ Runnable onPermissionsDenied = mock(Runnable.class);
+
+ Activity activity = mockActivity();
+
+ Permissions.from(activity)
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .andFallback(onPermissionsDenied)
+ .run(onPermissionsGranted);
+
+ Permissions.onRequestPermissionsResult(activity, new String[]{
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ }, new int[]{
+ PackageManager.PERMISSION_DENIED
+ });
+
+ verify(onPermissionsDenied).run();
+ verify(onPermissionsGranted, never()).run();
+ }
+
+ @Test
+ public void testPromptingForNotGrantedPermissions() {
+ Activity activity = mockActivity();
+
+ PermissionsHelper helper = mockDenyingHelper();
+ Permissions.setPermissionHelper(helper);
+
+ Permissions.from(activity)
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .andFallback(mock(Runnable.class))
+ .run(mock(Runnable.class));
+
+ verify(helper).prompt(anyActivity(), any(String[].class));
+
+ Permissions.onRequestPermissionsResult(activity, new String[0], new int[0]);
+ }
+
+ @Test
+ public void testMultipleRequestsAreQueuedAndDispatchedSequentially() {
+ Activity activity = mockActivity();
+
+ PermissionsHelper helper = mockDenyingHelper();
+ Permissions.setPermissionHelper(helper);
+
+ Runnable onFirstPermissionGranted = mock(Runnable.class);
+ Runnable onSecondPermissionDenied = mock(Runnable.class);
+
+ Permissions.from(activity)
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .andFallback(mock(Runnable.class))
+ .run(onFirstPermissionGranted);
+
+ Permissions.from(activity)
+ .withPermissions(Manifest.permission.CAMERA)
+ .andFallback(onSecondPermissionDenied)
+ .run(mock(Runnable.class));
+
+
+ Permissions.onRequestPermissionsResult(activity, new String[] {
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ }, new int[] {
+ PackageManager.PERMISSION_GRANTED
+ });
+
+ verify(onFirstPermissionGranted).run();
+ verify(onSecondPermissionDenied, never()).run(); // Second request is queued but not executed yet
+
+ Permissions.onRequestPermissionsResult(activity, new String[]{
+ Manifest.permission.CAMERA
+ }, new int[]{
+ PackageManager.PERMISSION_DENIED
+ });
+
+ verify(onFirstPermissionGranted).run();
+ verify(onSecondPermissionDenied).run();
+
+ verify(helper, times(2)).prompt(anyActivity(), any(String[].class));
+ }
+
+ @Test
+ public void testSecondRequestWillNotPromptIfPermissionHasBeenGranted() {
+ Activity activity = mockActivity();
+
+ PermissionsHelper helper = mock(PermissionsHelper.class);
+ Permissions.setPermissionHelper(helper);
+ when(helper.hasPermissions(anyContext(), anyPermissions()))
+ .thenReturn(false)
+ .thenReturn(false)
+ .thenReturn(true); // Revaluation is successful
+
+ Permissions.from(activity)
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .andFallback(mock(Runnable.class))
+ .run(mock(Runnable.class));
+
+ Permissions.from(activity)
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .andFallback(mock(Runnable.class))
+ .run(mock(Runnable.class));
+
+ Permissions.onRequestPermissionsResult(activity, new String[]{
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ }, new int[]{
+ PackageManager.PERMISSION_GRANTED
+ });
+
+ verify(helper, times(1)).prompt(anyActivity(), any(String[].class));
+ }
+
+ @Test
+ public void testEmptyPermissionsArrayWillExecuteRunnableAndNotTryToPrompt() {
+ PermissionsHelper helper = spy(new PermissionsHelper());
+ Permissions.setPermissionHelper(helper);
+
+ Runnable onPermissionGranted = mock(Runnable.class);
+ Runnable onPermissionDenied = mock(Runnable.class);
+
+ Permissions.from(mockActivity())
+ .withPermissions()
+ .andFallback(onPermissionDenied)
+ .run(onPermissionGranted);
+
+ verify(onPermissionGranted).run();
+ verify(onPermissionDenied, never()).run();
+ verify(helper, never()).prompt(anyActivity(), any(String[].class));
+ }
+
+ @Test
+ public void testDoNotPromptBehavior() {
+ PermissionsHelper helper = mockDenyingHelper();
+ Permissions.setPermissionHelper(helper);
+
+ Permissions.from(mockActivity())
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .doNotPrompt()
+ .andFallback(mock(Runnable.class))
+ .run(mock(Runnable.class));
+
+ verify(helper, never()).prompt(anyActivity(), any(String[].class));
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testThrowsExceptionIfNeedstoPromptWithNonActivityContext() {
+ Permissions.setPermissionHelper(mockDenyingHelper());
+
+ Permissions.from(mock(Context.class))
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .andFallback(mock(Runnable.class))
+ .run(mock(Runnable.class));
+ }
+
+ @Test
+ public void testDoNotPromptIfFalse() {
+ Activity activity = mockActivity();
+
+ PermissionsHelper helper = mockDenyingHelper();
+ Permissions.setPermissionHelper(helper);
+
+ Permissions.from(activity)
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .doNotPromptIf(false)
+ .andFallback(mock(Runnable.class))
+ .run(mock(Runnable.class));
+
+ verify(helper).prompt(anyActivity(), any(String[].class));
+
+ Permissions.onRequestPermissionsResult(activity, new String[0], new int[0]);
+ }
+
+ @Test
+ public void testDoNotPromptIfTrue() {
+ PermissionsHelper helper = mockDenyingHelper();
+ Permissions.setPermissionHelper(helper);
+
+ Permissions.from(mockActivity())
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .doNotPromptIf(true)
+ .andFallback(mock(Runnable.class))
+ .run(mock(Runnable.class));
+
+ verify(helper, never()).prompt(anyActivity(), any(String[].class));
+ }
+
+ private Activity mockActivity() {
+ return mock(Activity.class);
+ }
+
+ private PermissionsHelper mockGrantingHelper() {
+ PermissionsHelper helper = mock(PermissionsHelper.class);
+ doReturn(true).when(helper).hasPermissions(any(Context.class), anyPermissions());
+ return helper;
+ }
+
+ private PermissionsHelper mockDenyingHelper() {
+ PermissionsHelper helper = mock(PermissionsHelper.class);
+ doReturn(false).when(helper).hasPermissions(any(Context.class), anyPermissions());
+ return helper;
+ }
+
+ private String anyPermissions() {
+ return Matchers.anyVararg();
+ }
+
+ private Activity anyActivity() {
+ return any(Activity.class);
+ }
+
+ private Context anyContext() {
+ return any(Context.class);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushManager.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushManager.java
new file mode 100644
index 000000000..42ae0f543
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushManager.java
@@ -0,0 +1,238 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.push;
+
+import org.json.JSONObject;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.gcm.GcmTokenClient;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.UUID;
+
+import static org.mockito.Matchers.anyBoolean;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Matchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+
+@RunWith(TestRunner.class)
+public class TestPushManager {
+ private PushState state;
+ private GcmTokenClient gcmTokenClient;
+ private PushClient pushClient;
+ private PushManager manager;
+
+ @Before
+ public void setUp() throws Exception {
+ state = new PushState(RuntimeEnvironment.application, "test.json");
+ gcmTokenClient = mock(GcmTokenClient.class);
+ doReturn(new Fetched("opaque-gcm-token", System.currentTimeMillis())).when(gcmTokenClient).getToken(anyString(), anyBoolean());
+
+ // Configure a mock PushClient.
+ pushClient = mock(PushClient.class);
+ doReturn(new RegisterUserAgentResponse("opaque-uaid", "opaque-secret"))
+ .when(pushClient)
+ .registerUserAgent(anyString());
+
+ doReturn(new SubscribeChannelResponse("opaque-chid", "https://localhost:8085/opaque-push-endpoint"))
+ .when(pushClient)
+ .subscribeChannel(anyString(), anyString(), isNull(String.class));
+
+ PushManager.PushClientFactory pushClientFactory = mock(PushManager.PushClientFactory.class);
+ doReturn(pushClient).when(pushClientFactory).getPushClient(anyString(), anyBoolean());
+
+ manager = new PushManager(state, gcmTokenClient, pushClientFactory);
+ }
+
+ private void assertOnlyConfigured(PushRegistration registration, String endpoint, boolean debug) {
+ Assert.assertNotNull(registration);
+ Assert.assertEquals(registration.autopushEndpoint, endpoint);
+ Assert.assertEquals(registration.debug, debug);
+ Assert.assertNull(registration.uaid.value);
+ }
+
+ private void assertRegistered(PushRegistration registration, String endpoint, boolean debug) {
+ Assert.assertNotNull(registration);
+ Assert.assertEquals(registration.autopushEndpoint, endpoint);
+ Assert.assertEquals(registration.debug, debug);
+ Assert.assertNotNull(registration.uaid.value);
+ }
+
+ private void assertSubscribed(PushSubscription subscription) {
+ Assert.assertNotNull(subscription);
+ Assert.assertNotNull(subscription.chid);
+ }
+
+ @Test
+ public void testConfigure() throws Exception {
+ PushRegistration registration = manager.configure("default", "http://localhost:8081", false, System.currentTimeMillis());
+ assertOnlyConfigured(registration, "http://localhost:8081", false);
+
+ registration = manager.configure("default", "http://localhost:8082", true, System.currentTimeMillis());
+ assertOnlyConfigured(registration, "http://localhost:8082", true);
+ }
+
+ @Test(expected=PushManager.ProfileNeedsConfigurationException.class)
+ public void testRegisterBeforeConfigure() throws Exception {
+ PushRegistration registration = state.getRegistration("default");
+ Assert.assertNull(registration);
+
+ // Trying to register a User Agent fails before configuration.
+ manager.registerUserAgent("default", System.currentTimeMillis());
+ }
+
+ @Test
+ public void testRegister() throws Exception {
+ PushRegistration registration = manager.configure("default", "http://localhost:8082", false, System.currentTimeMillis());
+ assertOnlyConfigured(registration, "http://localhost:8082", false);
+
+ // Let's register a User Agent, so that we can witness unregistration.
+ registration = manager.registerUserAgent("default", System.currentTimeMillis());
+ assertRegistered(registration, "http://localhost:8082", false);
+
+ // Changing the debug flag should update but not try to unregister the User Agent.
+ registration = manager.configure("default", "http://localhost:8082", true, System.currentTimeMillis());
+ assertRegistered(registration, "http://localhost:8082", true);
+
+ // Changing the configuration endpoint should update and try to unregister the User Agent.
+ registration = manager.configure("default", "http://localhost:8083", true, System.currentTimeMillis());
+ assertOnlyConfigured(registration, "http://localhost:8083", true);
+ }
+
+ @Test
+ public void testRegisterMultipleProfiles() throws Exception {
+ PushRegistration registration1 = manager.configure("default1", "http://localhost:8081", true, System.currentTimeMillis());
+ PushRegistration registration2 = manager.configure("default2", "http://localhost:8082", true, System.currentTimeMillis());
+ assertOnlyConfigured(registration1, "http://localhost:8081", true);
+ assertOnlyConfigured(registration2, "http://localhost:8082", true);
+ verify(gcmTokenClient, times(0)).getToken(anyString(), anyBoolean());
+
+ registration1 = manager.registerUserAgent("default1", System.currentTimeMillis());
+ assertRegistered(registration1, "http://localhost:8081", true);
+
+ registration2 = manager.registerUserAgent("default2", System.currentTimeMillis());
+ assertRegistered(registration2, "http://localhost:8082", true);
+
+ // Just the debug flag should not unregister the User Agent.
+ registration1 = manager.configure("default1", "http://localhost:8081", false, System.currentTimeMillis());
+ assertRegistered(registration1, "http://localhost:8081", false);
+
+ // But the configuration endpoint should unregister the correct User Agent.
+ registration2 = manager.configure("default2", "http://localhost:8083", false, System.currentTimeMillis());
+ }
+
+ @Test
+ public void testSubscribeChannel() throws Exception {
+ manager.configure("default", "http://localhost:8080", false, System.currentTimeMillis());
+ PushRegistration registration = manager.registerUserAgent("default", System.currentTimeMillis());
+ assertRegistered(registration, "http://localhost:8080", false);
+
+ // We should be able to register with non-null serviceData.
+ final JSONObject webpushData = new JSONObject();
+ webpushData.put("version", 5);
+ PushSubscription subscription = manager.subscribeChannel("default", "webpush", webpushData, null, System.currentTimeMillis());
+ assertSubscribed(subscription);
+
+ subscription = manager.registrationForSubscription(subscription.chid).getSubscription(subscription.chid);
+ Assert.assertNotNull(subscription);
+ Assert.assertEquals(5, subscription.serviceData.get("version"));
+
+ // We should be able to register with null serviceData.
+ subscription = manager.subscribeChannel("default", "sync", null, null, System.currentTimeMillis());
+ assertSubscribed(subscription);
+
+ subscription = manager.registrationForSubscription(subscription.chid).getSubscription(subscription.chid);
+ Assert.assertNotNull(subscription);
+ Assert.assertNull(subscription.serviceData);
+ }
+
+ @Test
+ public void testUnsubscribeChannel() throws Exception {
+ manager.configure("default", "http://localhost:8080", false, System.currentTimeMillis());
+ PushRegistration registration = manager.registerUserAgent("default", System.currentTimeMillis());
+ assertRegistered(registration, "http://localhost:8080", false);
+
+ // We should be able to register with non-null serviceData.
+ final JSONObject webpushData = new JSONObject();
+ webpushData.put("version", 5);
+ PushSubscription subscription = manager.subscribeChannel("default", "webpush", webpushData, null, System.currentTimeMillis());
+ assertSubscribed(subscription);
+
+ // No exception is success.
+ manager.unsubscribeChannel(subscription.chid);
+ }
+
+ public void testUnsubscribeUnknownChannel() throws Exception {
+ manager.configure("default", "http://localhost:8080", false, System.currentTimeMillis());
+ PushRegistration registration = manager.registerUserAgent("default", System.currentTimeMillis());
+ assertRegistered(registration, "http://localhost:8080", false);
+
+ doThrow(new RuntimeException())
+ .when(pushClient)
+ .unsubscribeChannel(anyString(), anyString(), anyString());
+
+ // Un-subscribing from an unknown channel succeeds: we just ignore the request.
+ manager.unsubscribeChannel(UUID.randomUUID().toString());
+ }
+
+ @Test
+ public void testStartupBeforeConfiguration() throws Exception {
+ verify(gcmTokenClient, never()).getToken(anyString(), anyBoolean());
+ manager.startup(System.currentTimeMillis());
+ verify(gcmTokenClient, times(1)).getToken(AppConstants.MOZ_ANDROID_GCM_SENDERID, false);
+ }
+
+ @Test
+ public void testStartupBeforeRegistration() throws Exception {
+ PushRegistration registration = manager.configure("default", "http://localhost:8080", true, System.currentTimeMillis());
+ assertOnlyConfigured(registration, "http://localhost:8080", true);
+
+ manager.startup(System.currentTimeMillis());
+ verify(gcmTokenClient, times(1)).getToken(anyString(), anyBoolean());
+ }
+
+ @Test
+ public void testStartupAfterRegistration() throws Exception {
+ PushRegistration registration = manager.configure("default", "http://localhost:8080", true, System.currentTimeMillis());
+ assertOnlyConfigured(registration, "http://localhost:8080", true);
+
+ registration = manager.registerUserAgent("default", System.currentTimeMillis());
+ assertRegistered(registration, "http://localhost:8080", true);
+
+ manager.startup(System.currentTimeMillis());
+
+ // Rather tautological.
+ PushRegistration updatedRegistration = manager.state.getRegistration("default");
+ Assert.assertEquals(registration.uaid, updatedRegistration.uaid);
+ }
+
+ @Test
+ public void testStartupAfterSubscription() throws Exception {
+ PushRegistration registration = manager.configure("default", "http://localhost:8080", true, System.currentTimeMillis());
+ assertOnlyConfigured(registration, "http://localhost:8080", true);
+
+ registration = manager.registerUserAgent("default", System.currentTimeMillis());
+ assertRegistered(registration, "http://localhost:8080", true);
+
+ PushSubscription subscription = manager.subscribeChannel("default", "webpush", null, null, System.currentTimeMillis());
+ assertSubscribed(subscription);
+
+ manager.startup(System.currentTimeMillis());
+
+ // Rather tautological.
+ registration = manager.registrationForSubscription(subscription.chid);
+ PushSubscription updatedSubscription = registration.getSubscription(subscription.chid);
+ Assert.assertEquals(subscription.chid, updatedSubscription.chid);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushState.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushState.java
new file mode 100644
index 000000000..cb7c7ec68
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushState.java
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.push;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import java.io.File;
+import java.io.FileOutputStream;
+
+@RunWith(TestRunner.class)
+public class TestPushState {
+ @Test
+ public void testRoundTrip() throws Exception {
+ final PushState state = new PushState(RuntimeEnvironment.application, "test.json");
+ // Fresh state should have no registrations (and no subscriptions).
+ Assert.assertTrue(state.registrations.isEmpty());
+
+ final PushRegistration registration = new PushRegistration("endpoint", true, Fetched.now("uaid"), "secret");
+ final PushSubscription subscription = new PushSubscription("chid", "profileName", "webpushEndpoint", "service", null);
+ registration.putSubscription("chid", subscription);
+ state.putRegistration("profileName", registration);
+ Assert.assertEquals(1, state.registrations.size());
+ state.checkpoint();
+
+ final PushState readState = new PushState(RuntimeEnvironment.application, "test.json");
+ Assert.assertEquals(1, readState.registrations.size());
+ final PushRegistration storedRegistration = readState.getRegistration("profileName");
+ Assert.assertEquals(registration, storedRegistration);
+
+ Assert.assertEquals(1, storedRegistration.subscriptions.size());
+ final PushSubscription storedSubscription = storedRegistration.getSubscription("chid");
+ Assert.assertEquals(subscription, storedSubscription);
+ }
+
+ @Test
+ public void testMissingRegistration() throws Exception {
+ final PushState state = new PushState(RuntimeEnvironment.application, "testMissingRegistration.json");
+ Assert.assertNull(state.getRegistration("missingProfileName"));
+ }
+
+ @Test
+ public void testMissingSubscription() throws Exception {
+ final PushRegistration registration = new PushRegistration("endpoint", true, Fetched.now("uaid"), "secret");
+ Assert.assertNull(registration.getSubscription("missingChid"));
+ }
+
+ @Test
+ public void testCorruptedJSON() throws Exception {
+ // Write some malformed JSON.
+ // TODO: use mcomella's helpers!
+ final File file = new File(RuntimeEnvironment.application.getApplicationInfo().dataDir, "testCorruptedJSON.json");
+ FileOutputStream fos = null;
+ try {
+ fos = new FileOutputStream(file);
+ fos.write("}".getBytes("UTF-8"));
+ } finally {
+ if (fos != null) {
+ fos.close();
+ }
+ }
+
+ final PushState state = new PushState(RuntimeEnvironment.application, "testCorruptedJSON.json");
+ Assert.assertTrue(state.getRegistrations().isEmpty());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestAutopushClient.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestAutopushClient.java
new file mode 100644
index 000000000..93e0d14e5
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestAutopushClient.java
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.push.autopush.test;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.push.autopush.AutopushClient;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+import org.mozilla.gecko.sync.Utils;
+
+@RunWith(TestRunner.class)
+public class TestAutopushClient {
+ @Test
+ public void testGetSenderID() throws Exception {
+ final AutopushClient client = new AutopushClient("https://updates-autopush-dev.stage.mozaws.net/v1/gcm/829133274407",
+ Utils.newSynchronousExecutor());
+ Assert.assertEquals("829133274407", client.getSenderIDFromServerURI());
+ }
+
+ @Test(expected=AutopushClientException.class)
+ public void testGetNoSenderID() throws Exception {
+ final AutopushClient client = new AutopushClient("https://updates-autopush-dev.stage.mozaws.net/v1/gcm",
+ Utils.newSynchronousExecutor());
+ client.getSenderIDFromServerURI();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestLiveAutopushClient.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestLiveAutopushClient.java
new file mode 100644
index 000000000..102ea34e4
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestLiveAutopushClient.java
@@ -0,0 +1,171 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.push.autopush.test;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.push.RegisterUserAgentResponse;
+import org.mozilla.gecko.push.SubscribeChannelResponse;
+import org.mozilla.gecko.push.autopush.AutopushClient;
+import org.mozilla.gecko.push.autopush.AutopushClient.RequestDelegate;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.net.BaseResource;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.CoreMatchers.startsWith;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * This test straddles an awkward line: it uses Mockito, but doesn't actually mock the service
+ * endpoint. That's why it's a <b>live</b> test: most of its value is checking that the client
+ * implementation and the upstream server implementation are corresponding correctly.
+ */
+@RunWith(TestRunner.class)
+@Ignore("Live test that requires network connection -- remove this line to run this test.")
+public class TestLiveAutopushClient {
+ final String serverURL = "https://updates-autopush.stage.mozaws.net/v1/gcm/829133274407";
+
+ protected AutopushClient client;
+
+ @Before
+ public void setUp() throws Exception {
+ BaseResource.rewriteLocalhost = false;
+ client = new AutopushClient(serverURL, Utils.newSynchronousExecutor());
+ }
+
+ protected <T> T assertSuccess(RequestDelegate<T> delegate, Class<T> klass) {
+ verify(delegate, never()).handleError(any(Exception.class));
+ verify(delegate, never()).handleFailure(any(AutopushClientException.class));
+
+ final ArgumentCaptor<T> register = ArgumentCaptor.forClass(klass);
+ verify(delegate).handleSuccess(register.capture());
+
+ return register.getValue();
+ }
+
+ protected <T> AutopushClientException assertFailure(RequestDelegate<T> delegate, Class<T> klass) {
+ verify(delegate, never()).handleError(any(Exception.class));
+ verify(delegate, never()).handleSuccess(any(klass));
+
+ final ArgumentCaptor<AutopushClientException> failure = ArgumentCaptor.forClass(AutopushClientException.class);
+ verify(delegate).handleFailure(failure.capture());
+
+ return failure.getValue();
+ }
+
+ @Test
+ public void testUserAgent() throws Exception {
+ final RequestDelegate<RegisterUserAgentResponse> registerDelegate = mock(RequestDelegate.class);
+ client.registerUserAgent(Utils.generateGuid(), registerDelegate);
+
+ final RegisterUserAgentResponse registerResponse = assertSuccess(registerDelegate, RegisterUserAgentResponse.class);
+ Assert.assertNotNull(registerResponse);
+ Assert.assertNotNull(registerResponse.uaid);
+ Assert.assertNotNull(registerResponse.secret);
+
+ // Reregistering with a new GUID should succeed.
+ final RequestDelegate<Void> reregisterDelegate = mock(RequestDelegate.class);
+ client.reregisterUserAgent(registerResponse.uaid, registerResponse.secret, Utils.generateGuid(), reregisterDelegate);
+
+ Assert.assertNull(assertSuccess(reregisterDelegate, Void.class));
+
+ // Unregistering should succeed.
+ final RequestDelegate<Void> unregisterDelegate = mock(RequestDelegate.class);
+ client.unregisterUserAgent(registerResponse.uaid, registerResponse.secret, unregisterDelegate);
+
+ Assert.assertNull(assertSuccess(unregisterDelegate, Void.class));
+
+ // Trying to unregister a second time should give a 404.
+ final RequestDelegate<Void> reunregisterDelegate = mock(RequestDelegate.class);
+ client.unregisterUserAgent(registerResponse.uaid, registerResponse.secret, reunregisterDelegate);
+
+ final AutopushClientException failureException = assertFailure(reunregisterDelegate, Void.class);
+ Assert.assertThat(failureException, instanceOf(AutopushClientException.AutopushClientRemoteException.class));
+ Assert.assertTrue(((AutopushClientException.AutopushClientRemoteException) failureException).isGone());
+ }
+
+ @Test
+ public void testChannel() throws Exception {
+ final RequestDelegate<RegisterUserAgentResponse> registerDelegate = mock(RequestDelegate.class);
+ client.registerUserAgent(Utils.generateGuid(), registerDelegate);
+
+ final RegisterUserAgentResponse registerResponse = assertSuccess(registerDelegate, RegisterUserAgentResponse.class);
+ Assert.assertNotNull(registerResponse);
+ Assert.assertNotNull(registerResponse.uaid);
+ Assert.assertNotNull(registerResponse.secret);
+
+ // We should be able to subscribe to a channel.
+ final RequestDelegate<SubscribeChannelResponse> subscribeDelegate = mock(RequestDelegate.class);
+ client.subscribeChannel(registerResponse.uaid, registerResponse.secret, null, subscribeDelegate);
+
+ final SubscribeChannelResponse subscribeResponse = assertSuccess(subscribeDelegate, SubscribeChannelResponse.class);
+ Assert.assertNotNull(subscribeResponse);
+ Assert.assertNotNull(subscribeResponse.channelID);
+ Assert.assertNotNull(subscribeResponse.endpoint);
+ Assert.assertThat(subscribeResponse.endpoint, startsWith(FxAccountUtils.getAudienceForURL(serverURL)));
+ Assert.assertThat(subscribeResponse.endpoint, containsString("/v1/"));
+
+ // And we should be able to unsubscribe.
+ final RequestDelegate<Void> unsubscribeDelegate = mock(RequestDelegate.class);
+ client.unsubscribeChannel(registerResponse.uaid, registerResponse.secret, subscribeResponse.channelID, unsubscribeDelegate);
+
+ Assert.assertNull(assertSuccess(unsubscribeDelegate, Void.class));
+
+ // We should be able to create a restricted subscription by specifying
+ // an ECDSA public key using the P-256 curve.
+ final RequestDelegate<SubscribeChannelResponse> subscribeWithKeyDelegate = mock(RequestDelegate.class);
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("ECDSA");
+ keyPairGenerator.initialize(256);
+ final KeyPair keyPair = keyPairGenerator.generateKeyPair();
+ final PublicKey publicKey = keyPair.getPublic();
+ String appServerKey = Base64.encodeBase64URLSafeString(publicKey.getEncoded());
+ client.subscribeChannel(registerResponse.uaid, registerResponse.secret, appServerKey, subscribeWithKeyDelegate);
+
+ final SubscribeChannelResponse subscribeWithKeyResponse = assertSuccess(subscribeWithKeyDelegate, SubscribeChannelResponse.class);
+ Assert.assertNotNull(subscribeWithKeyResponse);
+ Assert.assertNotNull(subscribeWithKeyResponse.channelID);
+ Assert.assertNotNull(subscribeWithKeyResponse.endpoint);
+ Assert.assertThat(subscribeWithKeyResponse.endpoint, startsWith(FxAccountUtils.getAudienceForURL(serverURL)));
+ Assert.assertThat(subscribeWithKeyResponse.endpoint, containsString("/v2/"));
+
+ // And we should be able to drop the restricted subscription.
+ final RequestDelegate<Void> unsubscribeWithKeyDelegate = mock(RequestDelegate.class);
+ client.unsubscribeChannel(registerResponse.uaid, registerResponse.secret, subscribeWithKeyResponse.channelID, unsubscribeWithKeyDelegate);
+
+ Assert.assertNull(assertSuccess(unsubscribeWithKeyDelegate, Void.class));
+
+ // Trying to unsubscribe a second time should give a 410.
+ final RequestDelegate<Void> reunsubscribeDelegate = mock(RequestDelegate.class);
+ client.unsubscribeChannel(registerResponse.uaid, registerResponse.secret, subscribeResponse.channelID, reunsubscribeDelegate);
+
+ final AutopushClientException reunsubscribeFailureException = assertFailure(reunsubscribeDelegate, Void.class);
+ Assert.assertThat(reunsubscribeFailureException, instanceOf(AutopushClientException.AutopushClientRemoteException.class));
+ Assert.assertTrue(((AutopushClientException.AutopushClientRemoteException) reunsubscribeFailureException).isGone());
+
+ // Trying to unsubscribe from a non-existent channel should give a 404. Right now it gives a 401!
+ final RequestDelegate<Void> badUnsubscribeDelegate = mock(RequestDelegate.class);
+ client.unsubscribeChannel(registerResponse.uaid + "BAD", registerResponse.secret, subscribeResponse.channelID, badUnsubscribeDelegate);
+
+ final AutopushClientException badUnsubscribeFailureException = assertFailure(badUnsubscribeDelegate, Void.class);
+ Assert.assertThat(badUnsubscribeFailureException, instanceOf(AutopushClientException.AutopushClientRemoteException.class));
+ Assert.assertTrue(((AutopushClientException.AutopushClientRemoteException) badUnsubscribeFailureException).isInvalidAuthentication());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestBase32.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestBase32.java
new file mode 100644
index 000000000..7047d67d3
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestBase32.java
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.crypto.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.apache.commons.codec.binary.Base32;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.Utils;
+
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestBase32 {
+
+ public static void assertSame(byte[] arrayOne, byte[] arrayTwo) {
+ assertTrue(Arrays.equals(arrayOne, arrayTwo));
+ }
+
+ @Test
+ public void testBase32() throws UnsupportedEncodingException {
+ byte[] decoded = new Base32().decode("MZXW6YTBOI======");
+ byte[] expected = "foobar".getBytes();
+ assertSame(decoded, expected);
+
+ byte[] encoded = new Base32().encode("fooba".getBytes());
+ expected = "MZXW6YTB".getBytes();
+ assertSame(encoded, expected);
+ }
+
+ @Test
+ public void testFriendlyBase32() {
+ // These checks are drawn from Firefox, test_utils_encodeBase32.js.
+ byte[] decoded = Utils.decodeFriendlyBase32("mzxw6ytb9jrgcztpn5rgc4tcme");
+ byte[] expected = "foobarbafoobarba".getBytes();
+ assertEquals(decoded.length, 16);
+ assertSame(decoded, expected);
+
+ // These are real values extracted from the Service object in a Firefox profile.
+ String base32Key = "6m8mv8ex2brqnrmsb9fjuvfg7y";
+ String expectedHex = "f316caac97d06306c5920b8a9a54a6fe";
+
+ byte[] computedBytes = Utils.decodeFriendlyBase32(base32Key);
+ byte[] expectedBytes = Utils.hex2Byte(expectedHex);
+
+ assertSame(computedBytes, expectedBytes);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestCryptoInfo.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestCryptoInfo.java
new file mode 100644
index 000000000..3e8d90e2f
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestCryptoInfo.java
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.crypto.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.CryptoInfo;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+
+import java.io.UnsupportedEncodingException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestCryptoInfo {
+
+ @Test
+ public void testEncryptedHMACIsSet() throws CryptoException, UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException {
+ KeyBundle kb = KeyBundle.withRandomKeys();
+ CryptoInfo encrypted = CryptoInfo.encrypt("plaintext".getBytes("UTF-8"), kb);
+ assertSame(kb, encrypted.getKeys());
+ assertTrue(encrypted.generatedHMACIsHMAC());
+ }
+
+ @Test
+ public void testRandomEncryptedDecrypted() throws CryptoException, UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException {
+ KeyBundle kb = KeyBundle.withRandomKeys();
+ byte[] plaintext = "plaintext".getBytes("UTF-8");
+ CryptoInfo info = CryptoInfo.encrypt(plaintext, kb);
+ byte[] iv = info.getIV();
+ info.decrypt();
+ assertArrayEquals(plaintext, info.getMessage());
+ assertSame(null, info.getHMAC());
+ assertArrayEquals(iv, info.getIV());
+ assertSame(kb, info.getKeys());
+ }
+
+ @Test
+ public void testDecrypt() throws CryptoException {
+ String base64CipherText = "NMsdnRulLwQsVcwxKW9XwaUe7ouJk5Wn" +
+ "80QhbD80l0HEcZGCynh45qIbeYBik0lg" +
+ "cHbKmlIxTJNwU+OeqipN+/j7MqhjKOGI" +
+ "lvbpiPQQLC6/ffF2vbzL0nzMUuSyvaQz" +
+ "yGGkSYM2xUFt06aNivoQTvU2GgGmUK6M" +
+ "vadoY38hhW2LCMkoZcNfgCqJ26lO1O0s" +
+ "EO6zHsk3IVz6vsKiJ2Hq6VCo7hu123wN" +
+ "egmujHWQSGyf8JeudZjKzfi0OFRRvvm4" +
+ "QAKyBWf0MgrW1F8SFDnVfkq8amCB7Nhd" +
+ "whgLWbN+21NitNwWYknoEWe1m6hmGZDg" +
+ "DT32uxzWxCV8QqqrpH/ZggViEr9uMgoy" +
+ "4lYaWqP7G5WKvvechc62aqnsNEYhH26A" +
+ "5QgzmlNyvB+KPFvPsYzxDnSCjOoRSLx7" +
+ "GG86wT59QZw=";
+ String base64IV = "GX8L37AAb2FZJMzIoXlX8w==";
+ String base16Hmac = "b1e6c18ac30deb70236bc0d65a46f7a4" +
+ "dce3b8b0e02cf92182b914e3afa5eebc";
+ String base64EncryptionKey = "9K/wLdXdw+nrTtXo4ZpECyHFNr4d7aYH" +
+ "qeg3KW9+m6Q=";
+ String base64HmacKey = "MMntEfutgLTc8FlTLQFms8/xMPmCldqP" +
+ "lq/QQXEjx70=";
+ String base64ExpectedBytes = "eyJpZCI6IjVxUnNnWFdSSlpYciIsImhp" +
+ "c3RVcmkiOiJmaWxlOi8vL1VzZXJzL2ph" +
+ "c29uL0xpYnJhcnkvQXBwbGljYXRpb24l" +
+ "MjBTdXBwb3J0L0ZpcmVmb3gvUHJvZmls" +
+ "ZXMva3NnZDd3cGsuTG9jYWxTeW5jU2Vy" +
+ "dmVyL3dlYXZlL2xvZ3MvIiwidGl0bGUi" +
+ "OiJJbmRleCBvZiBmaWxlOi8vL1VzZXJz" +
+ "L2phc29uL0xpYnJhcnkvQXBwbGljYXRp" +
+ "b24gU3VwcG9ydC9GaXJlZm94L1Byb2Zp" +
+ "bGVzL2tzZ2Q3d3BrLkxvY2FsU3luY1Nl" +
+ "cnZlci93ZWF2ZS9sb2dzLyIsInZpc2l0" +
+ "cyI6W3siZGF0ZSI6MTMxOTE0OTAxMjM3" +
+ "MjQyNSwidHlwZSI6MX1dfQ==";
+
+ CryptoInfo decrypted = CryptoInfo.decrypt(
+ Base64.decodeBase64(base64CipherText),
+ Base64.decodeBase64(base64IV),
+ Utils.hex2Byte(base16Hmac),
+ new KeyBundle(
+ Base64.decodeBase64(base64EncryptionKey),
+ Base64.decodeBase64(base64HmacKey))
+ );
+
+ assertArrayEquals(decrypted.getMessage(), Base64.decodeBase64(base64ExpectedBytes));
+ }
+
+ @Test
+ public void testEncrypt() throws CryptoException {
+ String base64CipherText = "NMsdnRulLwQsVcwxKW9XwaUe7ouJk5Wn" +
+ "80QhbD80l0HEcZGCynh45qIbeYBik0lg" +
+ "cHbKmlIxTJNwU+OeqipN+/j7MqhjKOGI" +
+ "lvbpiPQQLC6/ffF2vbzL0nzMUuSyvaQz" +
+ "yGGkSYM2xUFt06aNivoQTvU2GgGmUK6M" +
+ "vadoY38hhW2LCMkoZcNfgCqJ26lO1O0s" +
+ "EO6zHsk3IVz6vsKiJ2Hq6VCo7hu123wN" +
+ "egmujHWQSGyf8JeudZjKzfi0OFRRvvm4" +
+ "QAKyBWf0MgrW1F8SFDnVfkq8amCB7Nhd" +
+ "whgLWbN+21NitNwWYknoEWe1m6hmGZDg" +
+ "DT32uxzWxCV8QqqrpH/ZggViEr9uMgoy" +
+ "4lYaWqP7G5WKvvechc62aqnsNEYhH26A" +
+ "5QgzmlNyvB+KPFvPsYzxDnSCjOoRSLx7" +
+ "GG86wT59QZw=";
+ String base64IV = "GX8L37AAb2FZJMzIoXlX8w==";
+ String base16Hmac = "b1e6c18ac30deb70236bc0d65a46f7a4" +
+ "dce3b8b0e02cf92182b914e3afa5eebc";
+ String base64EncryptionKey = "9K/wLdXdw+nrTtXo4ZpECyHFNr4d7aYH" +
+ "qeg3KW9+m6Q=";
+ String base64HmacKey = "MMntEfutgLTc8FlTLQFms8/xMPmCldqP" +
+ "lq/QQXEjx70=";
+ String base64ExpectedBytes = "eyJpZCI6IjVxUnNnWFdSSlpYciIsImhp" +
+ "c3RVcmkiOiJmaWxlOi8vL1VzZXJzL2ph" +
+ "c29uL0xpYnJhcnkvQXBwbGljYXRpb24l" +
+ "MjBTdXBwb3J0L0ZpcmVmb3gvUHJvZmls" +
+ "ZXMva3NnZDd3cGsuTG9jYWxTeW5jU2Vy" +
+ "dmVyL3dlYXZlL2xvZ3MvIiwidGl0bGUi" +
+ "OiJJbmRleCBvZiBmaWxlOi8vL1VzZXJz" +
+ "L2phc29uL0xpYnJhcnkvQXBwbGljYXRp" +
+ "b24gU3VwcG9ydC9GaXJlZm94L1Byb2Zp" +
+ "bGVzL2tzZ2Q3d3BrLkxvY2FsU3luY1Nl" +
+ "cnZlci93ZWF2ZS9sb2dzLyIsInZpc2l0" +
+ "cyI6W3siZGF0ZSI6MTMxOTE0OTAxMjM3" +
+ "MjQyNSwidHlwZSI6MX1dfQ==";
+
+ CryptoInfo encrypted = CryptoInfo.encrypt(
+ Base64.decodeBase64(base64ExpectedBytes),
+ Base64.decodeBase64(base64IV),
+ new KeyBundle(
+ Base64.decodeBase64(base64EncryptionKey),
+ Base64.decodeBase64(base64HmacKey))
+ );
+
+ assertArrayEquals(Base64.decodeBase64(base64CipherText), encrypted.getMessage());
+ assertArrayEquals(Utils.hex2Byte(base16Hmac), encrypted.getHMAC());
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestHKDF.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestHKDF.java
new file mode 100644
index 000000000..09973eeff
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestHKDF.java
@@ -0,0 +1,143 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.crypto.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.HKDF;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+
+import java.util.Arrays;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/*
+ * This class tests the HKDF.java class.
+ * The tests are the 3 HMAC-based test cases
+ * from the RFC 5869 specification.
+ */
+@RunWith(TestRunner.class)
+public class TestHKDF {
+ @Test
+ public void testCase1() {
+ String IKM = "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b";
+ String salt = "000102030405060708090a0b0c";
+ String info = "f0f1f2f3f4f5f6f7f8f9";
+ int L = 42;
+ String PRK = "077709362c2e32df0ddc3f0dc47bba63" +
+ "90b6c73bb50f9c3122ec844ad7c2b3e5";
+ String OKM = "3cb25f25faacd57a90434f64d0362f2a" +
+ "2d2d0a90cf1a5a4c5db02d56ecc4c5bf" +
+ "34007208d5b887185865";
+
+ assertTrue(doStep1(IKM, salt, PRK));
+ assertTrue(doStep2(PRK, info, L, OKM));
+ }
+
+ @Test
+ public void testCase2() {
+ String IKM = "000102030405060708090a0b0c0d0e0f" +
+ "101112131415161718191a1b1c1d1e1f" +
+ "202122232425262728292a2b2c2d2e2f" +
+ "303132333435363738393a3b3c3d3e3f" +
+ "404142434445464748494a4b4c4d4e4f";
+ String salt = "606162636465666768696a6b6c6d6e6f" +
+ "707172737475767778797a7b7c7d7e7f" +
+ "808182838485868788898a8b8c8d8e8f" +
+ "909192939495969798999a9b9c9d9e9f" +
+ "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf";
+ String info = "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf" +
+ "c0c1c2c3c4c5c6c7c8c9cacbcccdcecf" +
+ "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf" +
+ "e0e1e2e3e4e5e6e7e8e9eaebecedeeef" +
+ "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff";
+ int L = 82;
+ String PRK = "06a6b88c5853361a06104c9ceb35b45c" +
+ "ef760014904671014a193f40c15fc244";
+ String OKM = "b11e398dc80327a1c8e7f78c596a4934" +
+ "4f012eda2d4efad8a050cc4c19afa97c" +
+ "59045a99cac7827271cb41c65e590e09" +
+ "da3275600c2f09b8367793a9aca3db71" +
+ "cc30c58179ec3e87c14c01d5c1f3434f" +
+ "1d87";
+
+ assertTrue(doStep1(IKM, salt, PRK));
+ assertTrue(doStep2(PRK, info, L, OKM));
+ }
+
+ @Test
+ public void testCase3() {
+ String IKM = "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b";
+ String salt = "";
+ String info = "";
+ int L = 42;
+ String PRK = "19ef24a32c717b167f33a91d6f648bdf" +
+ "96596776afdb6377ac434c1c293ccb04";
+ String OKM = "8da4e775a563c18f715f802a063c5a31" +
+ "b8a11f5c5ee1879ec3454e5f3c738d2d" +
+ "9d201395faa4b61a96c8";
+
+ assertTrue(doStep1(IKM, salt, PRK));
+ assertTrue(doStep2(PRK, info, L, OKM));
+ }
+
+ /*
+ * Tests the code for getting the keys necessary to
+ * decrypt the crypto keys bundle for Mozilla Sync.
+ *
+ * This operation is just a tailored version of the
+ * standard to get only the 2 keys we need.
+ */
+ @Test
+ public void testGetCryptoKeysBundleKeys() {
+ String username = "smqvooxj664hmrkrv6bw4r4vkegjhkns";
+ String friendlyBase32SyncKey = "gbh7teqqcgyzd65svjgibd7tqy";
+ String base64EncryptionKey = "069EnS3EtDK4y1tZ1AyKX+U7WEsWRp9bRIKLdW/7aoE=";
+ String base64HmacKey = "LF2YCS1QCgSNCf0BCQvQ06SGH8jqJDi9dKj0O+b0fwI=";
+
+ KeyBundle bundle = null;
+ try {
+ bundle = new KeyBundle(username, friendlyBase32SyncKey);
+ } catch (Exception e) {
+ fail("Unexpected exception " + e);
+ }
+
+ byte[] expectedEncryptionKey = Base64.decodeBase64(base64EncryptionKey);
+ byte[] expectedHMACKey = Base64.decodeBase64(base64HmacKey);
+ assertTrue(Arrays.equals(bundle.getEncryptionKey(), expectedEncryptionKey));
+ assertTrue(Arrays.equals(bundle.getHMACKey(), expectedHMACKey));
+ }
+
+ /*
+ * Helper to do step 1 of RFC 5869.
+ */
+ private boolean doStep1(String IKM, String salt, String PRK) {
+ try {
+ byte[] prkResult = HKDF.hkdfExtract(Utils.hex2Byte(salt), Utils.hex2Byte(IKM));
+ byte[] prkExpect = Utils.hex2Byte(PRK);
+ return Arrays.equals(prkResult, prkExpect);
+ } catch (Exception e) {
+ fail("Unexpected exception " + e);
+ }
+ return false;
+ }
+
+ /*
+ * Helper to do step 2 of RFC 5869.
+ */
+ private boolean doStep2(String PRK, String info, int L, String OKM) {
+ try {
+ byte[] okmResult = HKDF.hkdfExpand(Utils.hex2Byte(PRK), Utils.hex2Byte(info), L);
+ byte[] okmExpect = Utils.hex2Byte(OKM);
+ return Arrays.equals(okmResult, okmExpect);
+ } catch (Exception e) {
+ fail("Unexpected exception " + e);
+ }
+ return false;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestKeyBundle.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestKeyBundle.java
new file mode 100644
index 000000000..3c3edb9f8
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestKeyBundle.java
@@ -0,0 +1,65 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.crypto.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestKeyBundle {
+ @Test
+ public void testCreateKeyBundle() throws UnsupportedEncodingException, CryptoException {
+ String username = "smqvooxj664hmrkrv6bw4r4vkegjhkns";
+ String friendlyBase32SyncKey = "gbh7teqqcgyzd65svjgibd7tqy";
+ String base64EncryptionKey = "069EnS3EtDK4y1tZ1AyKX+U7WEsWRp9b" +
+ "RIKLdW/7aoE=";
+ String base64HmacKey = "LF2YCS1QCgSNCf0BCQvQ06SGH8jqJDi9" +
+ "dKj0O+b0fwI=";
+
+ KeyBundle keys = new KeyBundle(username, friendlyBase32SyncKey);
+ assertArrayEquals(keys.getEncryptionKey(), Base64.decodeBase64(base64EncryptionKey.getBytes("UTF-8")));
+ assertArrayEquals(keys.getHMACKey(), Base64.decodeBase64(base64HmacKey.getBytes("UTF-8")));
+ }
+
+ /*
+ * Basic sanity check to make sure length of keys is correct (32 bytes).
+ * Also make sure that the two keys are different.
+ */
+ @Test
+ public void testGenerateRandomKeys() throws CryptoException {
+ KeyBundle keys = KeyBundle.withRandomKeys();
+
+ assertEquals(32, keys.getEncryptionKey().length);
+ assertEquals(32, keys.getHMACKey().length);
+
+ boolean equal = Arrays.equals(keys.getEncryptionKey(), keys.getHMACKey());
+ assertEquals(false, equal);
+ }
+
+ @Test
+ public void testEquals() throws CryptoException {
+ KeyBundle k = KeyBundle.withRandomKeys();
+ KeyBundle o = KeyBundle.withRandomKeys();
+ assertFalse(k.equals("test"));
+ assertFalse(k.equals(o));
+ assertTrue(k.equals(k));
+ assertTrue(o.equals(o));
+ o.setHMACKey(k.getHMACKey());
+ assertFalse(o.equals(k));
+ o.setEncryptionKey(k.getEncryptionKey());
+ assertTrue(o.equals(k));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestPBKDF2.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestPBKDF2.java
new file mode 100644
index 000000000..d2d1d8271
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestPBKDF2.java
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.crypto.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.PBKDF2;
+
+import java.io.UnsupportedEncodingException;
+import java.security.GeneralSecurityException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+/**
+ * Test PBKDF2 implementations against vectors from
+ * <dl>
+ * <dt>SHA-256</dt>
+ * <dd><a href="https://github.com/ircmaxell/PHP-PasswordLib/blob/master/test/Data/Vectors/pbkdf2-draft-josefsson-sha256.test-vectors">https://github.com/ircmaxell/PHP-PasswordLib/blob/master/test/Data/Vectors/pbkdf2-draft-josefsson-sha256.test-vectors</a></dd>
+ * <dd><a href="https://gitorious.org/scrypt/nettle-scrypt/blobs/37c0d5288e991604fe33dba2f1724986a8dddf56/testsuite/pbkdf2-test.c">https://gitorious.org/scrypt/nettle-scrypt/blobs/37c0d5288e991604fe33dba2f1724986a8dddf56/testsuite/pbkdf2-test.c</a></dd>
+ * </dl>
+ */
+@RunWith(TestRunner.class)
+public class TestPBKDF2 {
+
+ @Test
+ public final void testPBKDF2SHA256A() throws UnsupportedEncodingException, GeneralSecurityException {
+ String p = "password";
+ String s = "salt";
+ int dkLen = 32;
+
+ checkPBKDF2SHA256(p, s, 1, dkLen, "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b");
+ checkPBKDF2SHA256(p, s, 4096, dkLen, "c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a");
+ }
+
+ @Test
+ public final void testPBKDF2SHA256B() throws UnsupportedEncodingException, GeneralSecurityException {
+ String p = "passwordPASSWORDpassword";
+ String s = "saltSALTsaltSALTsaltSALTsaltSALTsalt";
+ int dkLen = 40;
+
+ checkPBKDF2SHA256(p, s, 4096, dkLen, "348c89dbcbd32b2f32d814b8116e84cf2b17347ebc1800181c4e2a1fb8dd53e1c635518c7dac47e9");
+ }
+
+ @Test
+ public final void testPBKDF2SHA256scryptA() throws UnsupportedEncodingException, GeneralSecurityException {
+ String p = "passwd";
+ String s = "salt";
+ int dkLen = 64;
+
+ checkPBKDF2SHA256(p, s, 1, dkLen, "55ac046e56e3089fec1691c22544b605f94185216dde0465e68b9d57c20dacbc49ca9cccf179b645991664b39d77ef317c71b845b1e30bd509112041d3a19783");
+ }
+
+ /*
+ // This test takes eight seconds or so to run, so we don't run it.
+ @Test
+ public final void testPBKDF2SHA256scryptB() throws UnsupportedEncodingException, GeneralSecurityException {
+ String p = "Password";
+ String s = "NaCl";
+ int dkLen = 64;
+
+ checkPBKDF2SHA256(p, s, 80000, dkLen, "4ddcd8f60b98be21830cee5ef22701f9641a4418d04c0414aeff08876b34ab56a1d425a1225833549adb841b51c9b3176a272bdebba1d078478f62b397f33c8d");
+ }
+ */
+
+ @Test
+ public final void testPBKDF2SHA256C() throws UnsupportedEncodingException, GeneralSecurityException {
+ String p = "pass\0word";
+ String s = "sa\0lt";
+ int dkLen = 16;
+
+ checkPBKDF2SHA256(p, s, 4096, dkLen, "89b69d0516f829893c696226650a8687");
+ }
+
+ /*
+ // This test takes two or three minutes to run, so we don't run it.
+ public final void testPBKDF2SHA256D() throws UnsupportedEncodingException, GeneralSecurityException {
+ String p = "password";
+ String s = "salt";
+ int dkLen = 32;
+
+ checkPBKDF2SHA256(p, s, 16777216, dkLen, "cf81c66fe8cfc04d1f31ecb65dab4089f7f179e89b3b0bcb17ad10e3ac6eba46");
+ }
+ */
+
+ /*
+ // This test takes eight seconds or so to run, so we don't run it.
+ @Test
+ public final void testTimePBKDF2SHA256() throws UnsupportedEncodingException, GeneralSecurityException {
+ checkPBKDF2SHA256("password", "salt", 80000, 32, null);
+ }
+ */
+
+ private void checkPBKDF2SHA256(String p, String s, int c, int dkLen,
+ final String expectedStr)
+ throws GeneralSecurityException, UnsupportedEncodingException {
+ long start = System.currentTimeMillis();
+ byte[] key = PBKDF2.pbkdf2SHA256(p.getBytes("US-ASCII"), s.getBytes("US-ASCII"), c, dkLen);
+ assertNotNull(key);
+
+ long end = System.currentTimeMillis();
+
+ System.err.println("SHA-256 " + c + " took " + (end - start) + "ms");
+ if (expectedStr == null) {
+ return;
+ }
+
+ assertEquals(dkLen, Utils.hex2Byte(expectedStr).length);
+ assertExpectedBytes(expectedStr, key);
+ }
+
+ public static void assertExpectedBytes(final String expectedStr, byte[] key) {
+ assertEquals(expectedStr, Utils.byte2Hex(key));
+ byte[] expected = Utils.hex2Byte(expectedStr);
+
+ assertEquals(expected.length, key.length);
+ for (int i = 0; i < key.length; i++) {
+ assertEquals(expected[i], key[i]);
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestPersistedCrypto5Keys.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestPersistedCrypto5Keys.java
new file mode 100644
index 000000000..f5ffc5a8a
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestPersistedCrypto5Keys.java
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.crypto.test;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CollectionKeys;
+import org.mozilla.gecko.sync.NoCollectionKeysSetException;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.crypto.PersistedCrypto5Keys;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+@RunWith(TestRunner.class)
+public class TestPersistedCrypto5Keys {
+ MockSharedPreferences prefs = null;
+
+ @Before
+ public void setUp() {
+ prefs = new MockSharedPreferences();
+ }
+
+ @Test
+ public void testPersistLastModified() throws CryptoException, NoCollectionKeysSetException {
+ long LAST_MODIFIED = System.currentTimeMillis();
+ KeyBundle syncKeyBundle = KeyBundle.withRandomKeys();
+ PersistedCrypto5Keys persisted = new PersistedCrypto5Keys(prefs, syncKeyBundle);
+
+ // Test fresh start.
+ assertEquals(-1, persisted.lastModified());
+
+ // Test persisting.
+ persisted.persistLastModified(LAST_MODIFIED);
+ assertEquals(LAST_MODIFIED, persisted.lastModified());
+
+ // Test clearing.
+ persisted.persistLastModified(0);
+ assertEquals(-1, persisted.lastModified());
+ }
+
+ @Test
+ public void testPersistKeys() throws CryptoException, NoCollectionKeysSetException {
+ KeyBundle syncKeyBundle = KeyBundle.withRandomKeys();
+ KeyBundle testKeyBundle = KeyBundle.withRandomKeys();
+
+ PersistedCrypto5Keys persisted = new PersistedCrypto5Keys(prefs, syncKeyBundle);
+
+ // Test fresh start.
+ assertNull(persisted.keys());
+
+ // Test persisting.
+ CollectionKeys keys = new CollectionKeys();
+ keys.setDefaultKeyBundle(syncKeyBundle);
+ keys.setKeyBundleForCollection("test", testKeyBundle);
+ persisted.persistKeys(keys);
+
+ CollectionKeys persistedKeys = persisted.keys();
+ assertNotNull(persistedKeys);
+ assertArrayEquals(syncKeyBundle.getEncryptionKey(), persistedKeys.defaultKeyBundle().getEncryptionKey());
+ assertArrayEquals(syncKeyBundle.getHMACKey(), persistedKeys.defaultKeyBundle().getHMACKey());
+ assertArrayEquals(testKeyBundle.getEncryptionKey(), persistedKeys.keyBundleForCollection("test").getEncryptionKey());
+ assertArrayEquals(testKeyBundle.getHMACKey(), persistedKeys.keyBundleForCollection("test").getHMACKey());
+
+ // Test clearing.
+ persisted.persistKeys(null);
+ assertNull(persisted.keys());
+
+ // Test loading a persisted bundle with wrong syncKeyBundle.
+ persisted.persistKeys(keys);
+ assertNotNull(persisted.keys());
+
+ persisted = new PersistedCrypto5Keys(prefs, testKeyBundle);
+ assertNull(persisted.keys());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestSRPConstants.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestSRPConstants.java
new file mode 100644
index 000000000..9188bba24
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestSRPConstants.java
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.crypto.test;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.net.SRPConstants;
+
+import java.math.BigInteger;
+
+@RunWith(TestRunner.class)
+public class TestSRPConstants extends SRPConstants {
+ public void assertSRPConstants(SRPConstants.Parameters params, int bitLength) {
+ Assert.assertNotNull(params.g);
+ Assert.assertNotNull(params.N);
+ Assert.assertEquals(bitLength, bitLength);
+ Assert.assertEquals(bitLength / 8, params.byteLength);
+ Assert.assertEquals(bitLength / 4, params.hexLength);
+ BigInteger N = params.N;
+ BigInteger g = params.g;
+ // Each prime N is of the form 2*q + 1, with q also prime.
+ BigInteger q = N.subtract(new BigInteger("1")).divide(new BigInteger("2"));
+ // Check that g is a generator: the order of g is exactly 2*q (not 2, not q).
+ Assert.assertFalse(new BigInteger("1").equals(g.modPow(new BigInteger("2"), N)));
+ Assert.assertFalse(new BigInteger("1").equals(g.modPow(q, N)));
+ Assert.assertTrue(new BigInteger("1").equals(g.modPow((N.subtract(new BigInteger("1"))), N)));
+ // Even probable primality checking is too expensive to do here.
+ // Assert.assertTrue(N.isProbablePrime(3));
+ // Assert.assertTrue(q.isProbablePrime(3));
+ }
+
+ @Test
+ public void testConstants() {
+ assertSRPConstants(SRPConstants._1024, 1024);
+ assertSRPConstants(SRPConstants._1536, 1536);
+ assertSRPConstants(SRPConstants._2048, 2048);
+ assertSRPConstants(SRPConstants._3072, 3072);
+ assertSRPConstants(SRPConstants._4096, 4096);
+ assertSRPConstants(SRPConstants._6144, 6144);
+ assertSRPConstants(SRPConstants._8192, 8192);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/middleware/test/TestCrypto5MiddlewareRepositorySession.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/middleware/test/TestCrypto5MiddlewareRepositorySession.java
new file mode 100644
index 000000000..d38a4caf2
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/middleware/test/TestCrypto5MiddlewareRepositorySession.java
@@ -0,0 +1,291 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.middleware.test;
+
+import junit.framework.AssertionFailedError;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.ExpectSuccessRepositorySessionBeginDelegate;
+import org.mozilla.android.sync.test.helpers.ExpectSuccessRepositorySessionCreationDelegate;
+import org.mozilla.android.sync.test.helpers.ExpectSuccessRepositorySessionFetchRecordsDelegate;
+import org.mozilla.android.sync.test.helpers.ExpectSuccessRepositorySessionFinishDelegate;
+import org.mozilla.android.sync.test.helpers.ExpectSuccessRepositorySessionStoreDelegate;
+import org.mozilla.android.sync.test.helpers.ExpectSuccessRepositoryWipeDelegate;
+import org.mozilla.gecko.background.testhelpers.MockRecord;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WBORepository;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.middleware.Crypto5MiddlewareRepository;
+import org.mozilla.gecko.sync.middleware.Crypto5MiddlewareRepositorySession;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+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.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestCrypto5MiddlewareRepositorySession {
+ public static WaitHelper getTestWaiter() {
+ return WaitHelper.getTestWaiter();
+ }
+
+ public static void performWait(Runnable runnable) {
+ getTestWaiter().performWait(runnable);
+ }
+
+ protected static void performNotify(InactiveSessionException e) {
+ final AssertionFailedError failed = new AssertionFailedError("Inactive session.");
+ failed.initCause(e);
+ getTestWaiter().performNotify(failed);
+ }
+
+ protected static void performNotify(InvalidSessionTransitionException e) {
+ final AssertionFailedError failed = new AssertionFailedError("Invalid session transition.");
+ failed.initCause(e);
+ getTestWaiter().performNotify(failed);
+ }
+
+ public Runnable onThreadRunnable(Runnable runnable) {
+ return WaitHelper.onThreadRunnable(runnable);
+ }
+
+ public WBORepository wboRepo;
+ public KeyBundle keyBundle;
+ public Crypto5MiddlewareRepository cmwRepo;
+ public Crypto5MiddlewareRepositorySession cmwSession;
+
+ @Before
+ public void setUp() throws CryptoException {
+ wboRepo = new WBORepository();
+ keyBundle = KeyBundle.withRandomKeys();
+ cmwRepo = new Crypto5MiddlewareRepository(wboRepo, keyBundle);
+ cmwSession = null;
+ }
+
+ /**
+ * Run `runnable` in performWait(... onBeginSucceeded { } ).
+ *
+ * The Crypto5MiddlewareRepositorySession is available in self.cmwSession.
+ *
+ * @param runnable
+ */
+ public void runInOnBeginSucceeded(final Runnable runnable) {
+ final TestCrypto5MiddlewareRepositorySession self = this;
+ performWait(onThreadRunnable(new Runnable() {
+ @Override
+ public void run() {
+ cmwRepo.createSession(new ExpectSuccessRepositorySessionCreationDelegate(getTestWaiter()) {
+ @Override
+ public void onSessionCreated(RepositorySession session) {
+ self.cmwSession = (Crypto5MiddlewareRepositorySession)session;
+ assertSame(RepositorySession.SessionStatus.UNSTARTED, cmwSession.getStatus());
+
+ try {
+ session.begin(new ExpectSuccessRepositorySessionBeginDelegate(getTestWaiter()) {
+ @Override
+ public void onBeginSucceeded(RepositorySession _session) {
+ assertSame(self.cmwSession, _session);
+ runnable.run();
+ }
+ });
+ } catch (InvalidSessionTransitionException e) {
+ TestCrypto5MiddlewareRepositorySession.performNotify(e);
+ }
+ }
+ }, null);
+ }
+ }));
+ }
+
+ @Test
+ /**
+ * Verify that the status is actually being advanced.
+ */
+ public void testStatus() {
+ runInOnBeginSucceeded(new Runnable() {
+ @Override public void run() {
+ assertSame(RepositorySession.SessionStatus.ACTIVE, cmwSession.getStatus());
+ try {
+ cmwSession.finish(new ExpectSuccessRepositorySessionFinishDelegate(getTestWaiter()));
+ } catch (InactiveSessionException e) {
+ performNotify(e);
+ }
+ }
+ });
+ assertSame(RepositorySession.SessionStatus.DONE, cmwSession.getStatus());
+ }
+
+ @Test
+ /**
+ * Verify that wipe is actually wiping the underlying repository.
+ */
+ public void testWipe() {
+ Record record = new MockRecord("nncdefghiaaa", "coll", System.currentTimeMillis(), false);
+ wboRepo.wbos.put(record.guid, record);
+ assertEquals(1, wboRepo.wbos.size());
+
+ runInOnBeginSucceeded(new Runnable() {
+ @Override public void run() {
+ cmwSession.wipe(new ExpectSuccessRepositoryWipeDelegate(getTestWaiter()));
+ }
+ });
+ performWait(onThreadRunnable(new Runnable() {
+ @Override public void run() {
+ try {
+ cmwSession.finish(new ExpectSuccessRepositorySessionFinishDelegate(getTestWaiter()));
+ } catch (InactiveSessionException e) {
+ performNotify(e);
+ }
+ }
+ }));
+ assertEquals(0, wboRepo.wbos.size());
+ }
+
+ @Test
+ /**
+ * Verify that store is actually writing encrypted data to the underlying repository.
+ */
+ public void testStoreEncrypts() throws NonObjectJSONException, CryptoException, IOException {
+ final BookmarkRecord record = new BookmarkRecord("nncdefghiaaa", "coll", System.currentTimeMillis(), false);
+ record.title = "unencrypted title";
+
+ runInOnBeginSucceeded(new Runnable() {
+ @Override public void run() {
+ try {
+ try {
+ cmwSession.setStoreDelegate(new ExpectSuccessRepositorySessionStoreDelegate(getTestWaiter()));
+ cmwSession.store(record);
+ } catch (NoStoreDelegateException e) {
+ getTestWaiter().performNotify(new AssertionFailedError("Should not happen."));
+ }
+ cmwSession.storeDone();
+ cmwSession.finish(new ExpectSuccessRepositorySessionFinishDelegate(getTestWaiter()));
+ } catch (InactiveSessionException e) {
+ performNotify(e);
+ }
+ }
+ });
+ assertEquals(1, wboRepo.wbos.size());
+ assertTrue(wboRepo.wbos.containsKey(record.guid));
+
+ Record storedRecord = wboRepo.wbos.get(record.guid);
+ CryptoRecord cryptoRecord = (CryptoRecord)storedRecord;
+ assertSame(cryptoRecord.keyBundle, keyBundle);
+
+ cryptoRecord = cryptoRecord.decrypt();
+ BookmarkRecord decryptedRecord = new BookmarkRecord();
+ decryptedRecord.initFromEnvelope(cryptoRecord);
+ assertEquals(record.title, decryptedRecord.title);
+ }
+
+ @Test
+ /**
+ * Verify that fetch is actually retrieving encrypted data from the underlying repository and is correctly decrypting it.
+ */
+ public void testFetchDecrypts() throws UnsupportedEncodingException, CryptoException {
+ final BookmarkRecord record1 = new BookmarkRecord("nncdefghiaaa", "coll", System.currentTimeMillis(), false);
+ record1.title = "unencrypted title";
+ final BookmarkRecord record2 = new BookmarkRecord("XXXXXXXXXXXX", "coll", System.currentTimeMillis(), false);
+ record2.title = "unencrypted second title";
+
+ CryptoRecord encryptedRecord1 = record1.getEnvelope();
+ encryptedRecord1.keyBundle = keyBundle;
+ encryptedRecord1 = encryptedRecord1.encrypt();
+ wboRepo.wbos.put(record1.guid, encryptedRecord1);
+
+ CryptoRecord encryptedRecord2 = record2.getEnvelope();
+ encryptedRecord2.keyBundle = keyBundle;
+ encryptedRecord2 = encryptedRecord2.encrypt();
+ wboRepo.wbos.put(record2.guid, encryptedRecord2);
+
+ final ExpectSuccessRepositorySessionFetchRecordsDelegate fetchRecordsDelegate = new ExpectSuccessRepositorySessionFetchRecordsDelegate(getTestWaiter());
+ runInOnBeginSucceeded(new Runnable() {
+ @Override public void run() {
+ try {
+ cmwSession.fetch(new String[] { record1.guid }, fetchRecordsDelegate);
+ } catch (InactiveSessionException e) {
+ performNotify(e);
+ }
+ }
+ });
+ performWait(onThreadRunnable(new Runnable() {
+ @Override public void run() {
+ try {
+ cmwSession.finish(new ExpectSuccessRepositorySessionFinishDelegate(getTestWaiter()));
+ } catch (InactiveSessionException e) {
+ performNotify(e);
+ }
+ }
+ }));
+
+ assertEquals(1, fetchRecordsDelegate.fetchedRecords.size());
+ BookmarkRecord decryptedRecord = new BookmarkRecord();
+ decryptedRecord.initFromEnvelope((CryptoRecord)fetchRecordsDelegate.fetchedRecords.get(0));
+ assertEquals(record1.title, decryptedRecord.title);
+ }
+
+ @Test
+ /**
+ * Verify that fetchAll is actually retrieving encrypted data from the underlying repository and is correctly decrypting it.
+ */
+ public void testFetchAllDecrypts() throws UnsupportedEncodingException, CryptoException {
+ final BookmarkRecord record1 = new BookmarkRecord("nncdefghiaaa", "coll", System.currentTimeMillis(), false);
+ record1.title = "unencrypted title";
+ final BookmarkRecord record2 = new BookmarkRecord("XXXXXXXXXXXX", "coll", System.currentTimeMillis(), false);
+ record2.title = "unencrypted second title";
+
+ CryptoRecord encryptedRecord1 = record1.getEnvelope();
+ encryptedRecord1.keyBundle = keyBundle;
+ encryptedRecord1 = encryptedRecord1.encrypt();
+ wboRepo.wbos.put(record1.guid, encryptedRecord1);
+
+ CryptoRecord encryptedRecord2 = record2.getEnvelope();
+ encryptedRecord2.keyBundle = keyBundle;
+ encryptedRecord2 = encryptedRecord2.encrypt();
+ wboRepo.wbos.put(record2.guid, encryptedRecord2);
+
+ final ExpectSuccessRepositorySessionFetchRecordsDelegate fetchAllRecordsDelegate = new ExpectSuccessRepositorySessionFetchRecordsDelegate(getTestWaiter());
+ runInOnBeginSucceeded(new Runnable() {
+ @Override public void run() {
+ cmwSession.fetchAll(fetchAllRecordsDelegate);
+ }
+ });
+ performWait(onThreadRunnable(new Runnable() {
+ @Override public void run() {
+ try {
+ cmwSession.finish(new ExpectSuccessRepositorySessionFinishDelegate(getTestWaiter()));
+ } catch (InactiveSessionException e) {
+ performNotify(e);
+ }
+ }
+ }));
+
+ assertEquals(2, fetchAllRecordsDelegate.fetchedRecords.size());
+ BookmarkRecord decryptedRecord1 = new BookmarkRecord();
+ decryptedRecord1.initFromEnvelope((CryptoRecord)fetchAllRecordsDelegate.fetchedRecords.get(0));
+ BookmarkRecord decryptedRecord2 = new BookmarkRecord();
+ decryptedRecord2.initFromEnvelope((CryptoRecord)fetchAllRecordsDelegate.fetchedRecords.get(1));
+
+ // We should get two different decrypted records
+ assertFalse(decryptedRecord1.guid.equals(decryptedRecord2.guid));
+ assertFalse(decryptedRecord1.title.equals(decryptedRecord2.title));
+ // And we should know about both.
+ assertTrue(record1.title.equals(decryptedRecord1.title) || record1.title.equals(decryptedRecord2.title));
+ assertTrue(record2.title.equals(decryptedRecord1.title) || record2.title.equals(decryptedRecord2.title));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestHMACAuthHeaderProvider.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestHMACAuthHeaderProvider.java
new file mode 100644
index 000000000..675351be9
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestHMACAuthHeaderProvider.java
@@ -0,0 +1,165 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.net.test;
+
+import ch.boye.httpclientandroidlib.client.methods.HttpGet;
+import ch.boye.httpclientandroidlib.client.methods.HttpPost;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.net.HMACAuthHeaderProvider;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+import static org.junit.Assert.assertEquals;
+
+@RunWith(TestRunner.class)
+public class TestHMACAuthHeaderProvider {
+ // Expose a few protected static member functions as public for testing.
+ protected static class LeakyHMACAuthHeaderProvider extends HMACAuthHeaderProvider {
+ public LeakyHMACAuthHeaderProvider(String identifier, String key) {
+ super(identifier, key);
+ }
+
+ public static String getRequestString(HttpUriRequest request, long timestampInSeconds, String nonce, String extra) {
+ return HMACAuthHeaderProvider.getRequestString(request, timestampInSeconds, nonce, extra);
+ }
+
+ public static String getSignature(String requestString, String key)
+ throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
+ return HMACAuthHeaderProvider.getSignature(requestString, key);
+ }
+ }
+
+ @Test
+ public void testGetRequestStringSpecExample1() throws Exception {
+ long timestamp = 1336363200;
+ String nonceString = "dj83hs9s";
+ String extra = "";
+ URI uri = new URI("http://example.com/resource/1?b=1&a=2");
+
+ HttpUriRequest req = new HttpGet(uri);
+
+ String expected = "1336363200\n" +
+ "dj83hs9s\n" +
+ "GET\n" +
+ "/resource/1?b=1&a=2\n" +
+ "example.com\n" +
+ "80\n" +
+ "\n";
+
+ assertEquals(expected, LeakyHMACAuthHeaderProvider.getRequestString(req, timestamp, nonceString, extra));
+ }
+
+ @Test
+ public void testGetRequestStringSpecExample2() throws Exception {
+ long timestamp = 264095;
+ String nonceString = "7d8f3e4a";
+ String extra = "a,b,c";
+ URI uri = new URI("http://example.com/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b&c2&a3=2+q");
+
+ HttpUriRequest req = new HttpPost(uri);
+
+ String expected = "264095\n" +
+ "7d8f3e4a\n" +
+ "POST\n" +
+ "/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b&c2&a3=2+q\n" +
+ "example.com\n" +
+ "80\n" +
+ "a,b,c\n";
+
+ assertEquals(expected, LeakyHMACAuthHeaderProvider.getRequestString(req, timestamp, nonceString, extra));
+ }
+
+ @Test
+ public void testPort() throws Exception {
+ long timestamp = 264095;
+ String nonceString = "7d8f3e4a";
+ String extra = "a,b,c";
+ URI uri = new URI("http://example.com:88/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b&c2&a3=2+q");
+
+ HttpUriRequest req = new HttpPost(uri);
+
+ String expected = "264095\n" +
+ "7d8f3e4a\n" +
+ "POST\n" +
+ "/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b&c2&a3=2+q\n" +
+ "example.com\n" +
+ "88\n" +
+ "a,b,c\n";
+
+ assertEquals(expected, LeakyHMACAuthHeaderProvider.getRequestString(req, timestamp, nonceString, extra));
+ }
+
+ @Test
+ public void testHTTPS() throws Exception {
+ long timestamp = 264095;
+ String nonceString = "7d8f3e4a";
+ String extra = "a,b,c";
+ URI uri = new URI("https://example.com/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b&c2&a3=2+q");
+
+ HttpUriRequest req = new HttpPost(uri);
+
+ String expected = "264095\n" +
+ "7d8f3e4a\n" +
+ "POST\n" +
+ "/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b&c2&a3=2+q\n" +
+ "example.com\n" +
+ "443\n" +
+ "a,b,c\n";
+
+ assertEquals(expected, LeakyHMACAuthHeaderProvider.getRequestString(req, timestamp, nonceString, extra));
+ }
+
+ @Test
+ public void testSpecSignatureExample() throws Exception {
+ String extra = "";
+ long timestampInSeconds = 1336363200;
+ String nonceString = "dj83hs9s";
+
+ URI uri = new URI("http://example.com/resource/1?b=1&a=2");
+ HttpRequestBase req = new HttpGet(uri);
+
+ String requestString = LeakyHMACAuthHeaderProvider.getRequestString(req, timestampInSeconds, nonceString, extra);
+
+ String expected = "1336363200\n" +
+ "dj83hs9s\n" +
+ "GET\n" +
+ "/resource/1?b=1&a=2\n" +
+ "example.com\n" +
+ "80\n" +
+ "\n";
+
+ assertEquals(expected, requestString);
+
+ // There appears to be an error in the current spec.
+ // Spec is at https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01#section-1.1
+ // Error is reported at http://www.ietf.org/mail-archive/web/oauth/current/msg09741.html
+ // assertEquals("bhCQXTVyfj5cmA9uKkPFx1zeOXM=", HMACAuthHeaderProvider.getSignature(requestString, keyString));
+ }
+
+ @Test
+ public void testCompatibleWithDesktopFirefox() throws Exception {
+ // These are test values used in the FF Sync Client testsuite.
+
+ // String identifier = "vmo1txkttblmn51u2p3zk2xiy16hgvm5ok8qiv1yyi86ffjzy9zj0ez9x6wnvbx7";
+ String keyString = "b8u1cc5iiio5o319og7hh8faf2gi5ym4aq0zwf112cv1287an65fudu5zj7zo7dz";
+
+ String extra = "";
+ long timestampInSeconds = 1329181221;
+ String nonceString = "wGX71";
+
+ URI uri = new URI("http://10.250.2.176/alias/");
+ HttpRequestBase req = new HttpGet(uri);
+
+ String requestString = LeakyHMACAuthHeaderProvider.getRequestString(req, timestampInSeconds, nonceString, extra);
+
+ assertEquals("jzh5chjQc2zFEvLbyHnPdX11Yck=", LeakyHMACAuthHeaderProvider.getSignature(requestString, keyString));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestHawkAuthHeaderProvider.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestHawkAuthHeaderProvider.java
new file mode 100644
index 000000000..3dab313a0
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestHawkAuthHeaderProvider.java
@@ -0,0 +1,145 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.net.test;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.client.methods.HttpGet;
+import ch.boye.httpclientandroidlib.client.methods.HttpPost;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
+import ch.boye.httpclientandroidlib.entity.StringEntity;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+import ch.boye.httpclientandroidlib.message.BasicHeader;
+import ch.boye.httpclientandroidlib.protocol.BasicHttpContext;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.net.HawkAuthHeaderProvider;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * These test vectors were taken from
+ * <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/README.md">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/README.md</a>.
+ */
+@RunWith(TestRunner.class)
+public class TestHawkAuthHeaderProvider {
+ // Expose a few protected static member functions as public for testing.
+ protected static class LeakyHawkAuthHeaderProvider extends HawkAuthHeaderProvider {
+ public LeakyHawkAuthHeaderProvider(String tokenId, byte[] reqHMACKey) {
+ // getAuthHeader takes includePayloadHash as a parameter.
+ super(tokenId, reqHMACKey, false, 0L);
+ }
+
+ // Public for testing.
+ public static String getRequestString(HttpUriRequest request, String type, long timestamp, String nonce, String hash, String extra, String app, String dlg) {
+ return HawkAuthHeaderProvider.getRequestString(request, type, timestamp, nonce, hash, extra, app, dlg);
+ }
+
+ // Public for testing.
+ public static String getSignature(String requestString, String key)
+ throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
+ return HawkAuthHeaderProvider.getSignature(requestString.getBytes("UTF-8"), key.getBytes("UTF-8"));
+ }
+
+ // Public for testing.
+ @Override
+ public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client,
+ long timestamp, String nonce, String extra, boolean includePayloadHash)
+ throws InvalidKeyException, NoSuchAlgorithmException, IOException {
+ return super.getAuthHeader(request, context, client, timestamp, nonce, extra, includePayloadHash);
+ }
+
+ // Public for testing.
+ public static String getBaseContentType(Header contentTypeHeader) {
+ return HawkAuthHeaderProvider.getBaseContentType(contentTypeHeader);
+ }
+ }
+
+ @Test
+ public void testSpecRequestString() throws Exception {
+ long timestamp = 1353832234;
+ String nonce = "j4h3g2";
+ String extra = "some-app-ext-data";
+ String hash = null;
+ String app = null;
+ String dlg = null;
+
+ URI uri = new URI("http://example.com:8000/resource/1?b=1&a=2");
+ HttpUriRequest req = new HttpGet(uri);
+
+ String expected = "hawk.1.header\n" +
+ "1353832234\n" +
+ "j4h3g2\n" +
+ "GET\n" +
+ "/resource/1?b=1&a=2\n" +
+ "example.com\n" +
+ "8000\n" +
+ "\n" +
+ "some-app-ext-data\n";
+
+ // LeakyHawkAuthHeaderProvider.
+ assertEquals(expected, LeakyHawkAuthHeaderProvider.getRequestString(req, "header", timestamp, nonce, hash, extra, app, dlg));
+ }
+
+ @Test
+ public void testSpecSignatureExample() throws Exception {
+ String input = "hawk.1.header\n" +
+ "1353832234\n" +
+ "j4h3g2\n" +
+ "GET\n" +
+ "/resource/1?b=1&a=2\n" +
+ "example.com\n" +
+ "8000\n" +
+ "\n" +
+ "some-app-ext-data\n";
+
+ assertEquals("6R4rV5iE+NPoym+WwjeHzjAGXUtLNIxmo1vpMofpLAE=", LeakyHawkAuthHeaderProvider.getSignature(input, "werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn"));
+ }
+
+ @Test
+ public void testSpecPayloadExample() throws Exception {
+ LeakyHawkAuthHeaderProvider provider = new LeakyHawkAuthHeaderProvider("dh37fgj492je", "werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn".getBytes("UTF-8"));
+ URI uri = new URI("http://example.com:8000/resource/1?b=1&a=2");
+ HttpPost req = new HttpPost(uri);
+ String body = "Thank you for flying Hawk";
+ req.setEntity(new StringEntity(body));
+ Header header = provider.getAuthHeader(req, null, null, 1353832234L, "j4h3g2", "some-app-ext-data", true);
+ String expected = "Hawk id=\"dh37fgj492je\", ts=\"1353832234\", nonce=\"j4h3g2\", hash=\"Yi9LfIIFRtBEPt74PVmbTF/xVAwPn7ub15ePICfgnuY=\", ext=\"some-app-ext-data\", mac=\"aSe1DERmZuRl3pI36/9BdZmnErTw3sNzOOAUlfeKjVw=\"";
+ assertEquals("Authorization", header.getName());
+ assertEquals(expected, header.getValue());
+ }
+
+ @Test
+ public void testSpecAuthorizationHeader() throws Exception {
+ LeakyHawkAuthHeaderProvider provider = new LeakyHawkAuthHeaderProvider("dh37fgj492je", "werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn".getBytes("UTF-8"));
+ URI uri = new URI("http://example.com:8000/resource/1?b=1&a=2");
+ HttpGet req = new HttpGet(uri);
+ Header header = provider.getAuthHeader(req, null, null, 1353832234L, "j4h3g2", "some-app-ext-data", false);
+ String expected = "Hawk id=\"dh37fgj492je\", ts=\"1353832234\", nonce=\"j4h3g2\", ext=\"some-app-ext-data\", mac=\"6R4rV5iE+NPoym+WwjeHzjAGXUtLNIxmo1vpMofpLAE=\"";
+ assertEquals("Authorization", header.getName());
+ assertEquals(expected, header.getValue());
+
+ // For a non-POST, non-PUT request, a request to include the payload verification hash is silently ignored.
+ header = provider.getAuthHeader(req, null, null, 1353832234L, "j4h3g2", "some-app-ext-data", true);
+ assertEquals("Authorization", header.getName());
+ assertEquals(expected, header.getValue());
+ }
+
+ @Test
+ public void testGetBaseContentType() throws Exception {
+ assertEquals("text/plain", LeakyHawkAuthHeaderProvider.getBaseContentType(new BasicHeader("Content-Type", "text/plain")));
+ assertEquals("text/plain", LeakyHawkAuthHeaderProvider.getBaseContentType(new BasicHeader("Content-Type", "text/plain;one")));
+ assertEquals("text/plain", LeakyHawkAuthHeaderProvider.getBaseContentType(new BasicHeader("Content-Type", "text/plain;one;two")));
+ assertEquals("text/html", LeakyHawkAuthHeaderProvider.getBaseContentType(new BasicHeader("Content-Type", "text/html;charset=UTF-8")));
+ assertEquals("text/html", LeakyHawkAuthHeaderProvider.getBaseContentType(new BasicHeader("Content-Type", "text/html; charset=UTF-8")));
+ assertEquals("text/html", LeakyHawkAuthHeaderProvider.getBaseContentType(new BasicHeader("Content-Type", "text/html ;charset=UTF-8")));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestLiveHawkAuth.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestLiveHawkAuth.java
new file mode 100644
index 000000000..8f136e3d0
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestLiveHawkAuth.java
@@ -0,0 +1,181 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.net.test;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.entity.StringEntity;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+import ch.boye.httpclientandroidlib.message.BasicHeader;
+import ch.boye.httpclientandroidlib.protocol.BasicHttpContext;
+import org.junit.Assert;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+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 java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.security.GeneralSecurityException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+
+public class TestLiveHawkAuth {
+ /**
+ * Hawk comes with an example/usage.js server. Modify it to serve indefinitely,
+ * un-comment the following line, and verify that the port and credentials
+ * have not changed; then the following test should pass.
+ */
+ // @org.junit.Test
+ public void testHawkUsage() throws Exception {
+ // Id and credentials are hard-coded in example/usage.js.
+ final String id = "dh37fgj492je";
+ final byte[] key = "werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn".getBytes("UTF-8");
+ final BaseResource resource = new BaseResource("http://localhost:8000/", false);
+
+ // Basic GET.
+ resource.delegate = new TestBaseResourceDelegate(resource, new HawkAuthHeaderProvider(id, key, false, 0L));
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ resource.get();
+ }
+ });
+
+ // PUT with payload verification.
+ resource.delegate = new TestBaseResourceDelegate(resource, new HawkAuthHeaderProvider(id, key, true, 0L));
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ resource.put(new StringEntity("Thank you for flying Hawk"));
+ } catch (UnsupportedEncodingException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+ });
+
+ // PUT with a large (32k or so) body and payload verification.
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 16000; i++) {
+ sb.append(Integer.valueOf(i % 100).toString());
+ }
+ resource.delegate = new TestBaseResourceDelegate(resource, new HawkAuthHeaderProvider(id, key, true, 0L));
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ resource.put(new StringEntity(sb.toString()));
+ } catch (UnsupportedEncodingException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+ });
+
+ // PUT without payload verification.
+ resource.delegate = new TestBaseResourceDelegate(resource, new HawkAuthHeaderProvider(id, key, false, 0L));
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ resource.put(new StringEntity("Thank you for flying Hawk"));
+ } catch (UnsupportedEncodingException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+ });
+
+ // PUT with *bad* payload verification.
+ HawkAuthHeaderProvider provider = new HawkAuthHeaderProvider(id, key, true, 0L) {
+ @Override
+ public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) throws GeneralSecurityException {
+ Header header = super.getAuthHeader(request, context, client);
+ // Here's a cheap way of breaking the hash.
+ String newValue = header.getValue().replaceAll("hash=\"....", "hash=\"XXXX");
+ return new BasicHeader(header.getName(), newValue);
+ }
+ };
+
+ resource.delegate = new TestBaseResourceDelegate(resource, provider);
+ try {
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ resource.put(new StringEntity("Thank you for flying Hawk"));
+ } catch (UnsupportedEncodingException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+ });
+ fail("Expected assertion after 401 response.");
+ } catch (WaitHelper.InnerError e) {
+ assertTrue(e.innerError instanceof AssertionError);
+ assertEquals("expected:<200> but was:<401>", ((AssertionError) e.innerError).getMessage());
+ }
+ }
+
+ protected final class TestBaseResourceDelegate extends BaseResourceDelegate {
+ protected final HawkAuthHeaderProvider provider;
+
+ protected TestBaseResourceDelegate(Resource resource, HawkAuthHeaderProvider provider) throws UnsupportedEncodingException {
+ super(resource);
+ this.provider = provider;
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return provider;
+ }
+
+ @Override
+ public int connectionTimeout() {
+ return 1000;
+ }
+
+ @Override
+ public int socketTimeout() {
+ return 1000;
+ }
+
+ @Override
+ public void handleHttpResponse(HttpResponse response) {
+ SyncResponse res = new SyncResponse(response);
+ try {
+ Assert.assertEquals(200, res.getStatusCode());
+ WaitHelper.getTestWaiter().performNotify();
+ } catch (Throwable e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+
+ @Override
+ public void handleTransportException(GeneralSecurityException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+
+ @Override
+ public void handleHttpProtocolException(ClientProtocolException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+
+ @Override
+ public void handleHttpIOException(IOException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+
+ @Override
+ public String getUserAgent() {
+ return null;
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestUserAgentHeaders.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestUserAgentHeaders.java
new file mode 100644
index 000000000..30b8a38ec
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestUserAgentHeaders.java
@@ -0,0 +1,131 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.net.test;
+
+import ch.boye.httpclientandroidlib.protocol.HTTP;
+import junit.framework.Assert;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.RequestDelegate;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.RecoveryEmailStatusResponse;
+import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.sync.SyncConstants;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.SyncStorageRecordRequest;
+import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import java.util.concurrent.Executors;
+
+@RunWith(TestRunner.class)
+public class TestUserAgentHeaders {
+ private static final int TEST_PORT = HTTPServerTestHelper.getTestPort();
+ private static final String TEST_SERVER = "http://localhost:" + TEST_PORT;
+
+ protected final HTTPServerTestHelper data = new HTTPServerTestHelper();
+
+ protected class UserAgentServer extends MockServer {
+ public String lastUserAgent = null;
+
+ @Override
+ public void handle(Request request, Response response) {
+ lastUserAgent = request.getValue(HTTP.USER_AGENT);
+ super.handle(request, response);
+ }
+ }
+
+ protected final UserAgentServer userAgentServer = new UserAgentServer();
+
+ @Before
+ public void setUp() {
+ BaseResource.rewriteLocalhost = false;
+ data.startHTTPServer(userAgentServer);
+ }
+
+ @After
+ public void tearDown() {
+ data.stopHTTPServer();
+ }
+
+ @Test
+ public void testSyncUserAgent() throws Exception {
+ final SyncStorageRecordRequest request = new SyncStorageRecordRequest(TEST_SERVER);
+ request.delegate = new SyncStorageRequestDelegate() {
+ @Override
+ public String ifUnmodifiedSince() {
+ return null;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return null;
+ }
+ };
+
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ request.get();
+ }
+ });
+
+ // Verify that we're getting the value from the correct place.
+ Assert.assertEquals(SyncConstants.USER_AGENT, userAgentServer.lastUserAgent);
+ }
+
+ @Test
+ public void testFxAccountClientUserAgent() throws Exception {
+ final FxAccountClient20 client = new FxAccountClient20(TEST_SERVER, Executors.newSingleThreadExecutor());
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ client.recoveryEmailStatus(new byte[] { 0 }, new RequestDelegate<RecoveryEmailStatusResponse>() {
+ @Override
+ public void handleSuccess(RecoveryEmailStatusResponse result) {
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public void handleFailure(FxAccountClientRemoteException e) {
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ WaitHelper.getTestWaiter().performNotify();
+ }
+ });
+ }
+ });
+
+ // Verify that we're getting the value from the correct place.
+ Assert.assertEquals(FxAccountConstants.USER_AGENT, userAgentServer.lastUserAgent);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpersTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpersTest.java
new file mode 100644
index 000000000..eecfa8dc2
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpersTest.java
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class BrowserContractHelpersTest {
+ @Test
+ public void testBookmarkCodes() {
+ final String[] strings = {
+ // Observe omissions: "microsummary", "item".
+ "folder", "bookmark", "separator", "livemark", "query"
+ };
+ for (int i = 0; i < strings.length; ++i) {
+ assertEquals(strings[i], BrowserContractHelpers.typeStringForCode(i));
+ assertEquals(i, BrowserContractHelpers.typeCodeForString(strings[i]));
+ }
+ assertEquals(null, BrowserContractHelpers.typeStringForCode(-1));
+ assertEquals(null, BrowserContractHelpers.typeStringForCode(100));
+
+ assertEquals(-1, BrowserContractHelpers.typeCodeForString(null));
+ assertEquals(-1, BrowserContractHelpers.typeCodeForString("folder "));
+ assertEquals(-1, BrowserContractHelpers.typeCodeForString("FOLDER"));
+ assertEquals(-1, BrowserContractHelpers.typeCodeForString(""));
+ assertEquals(-1, BrowserContractHelpers.typeCodeForString("nope"));
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/VisitsHelperTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/VisitsHelperTest.java
new file mode 100644
index 000000000..67bbca089
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/VisitsHelperTest.java
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.android;
+
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.net.Uri;
+
+import junit.framework.Assert;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.db.DelegatingTestContentProvider;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserProvider;
+import org.robolectric.shadows.ShadowContentResolver;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class VisitsHelperTest {
+ @Test
+ public void testBulkInsertRemoteVisits() throws Exception {
+ JSONArray toInsert = new JSONArray();
+ Assert.assertEquals(0, VisitsHelper.getVisitsContentValues("testGUID", toInsert).length);
+
+ JSONObject visit = new JSONObject();
+ Long date = Long.valueOf(123432552344l);
+ visit.put("date", date);
+ visit.put("type", 2l);
+ toInsert.add(visit);
+
+ JSONObject visit2 = new JSONObject();
+ visit2.put("date", date + 1000);
+ visit2.put("type", 5l);
+ toInsert.add(visit2);
+
+ ContentValues[] cvs = VisitsHelper.getVisitsContentValues("testGUID", toInsert);
+ Assert.assertEquals(2, cvs.length);
+ ContentValues cv1 = cvs[0];
+ ContentValues cv2 = cvs[1];
+ Assert.assertEquals(Integer.valueOf(2), cv1.getAsInteger(BrowserContract.Visits.VISIT_TYPE));
+ Assert.assertEquals(Integer.valueOf(5), cv2.getAsInteger(BrowserContract.Visits.VISIT_TYPE));
+
+ Assert.assertEquals(date, cv1.getAsLong("date"));
+ Assert.assertEquals(Long.valueOf(date + 1000), cv2.getAsLong(BrowserContract.Visits.DATE_VISITED));
+ }
+
+ @Test
+ public void testGetRecentHistoryVisitsForGUID() throws Exception {
+ Uri historyTestUri = testUri(BrowserContract.History.CONTENT_URI);
+ Uri visitsTestUri = testUri(BrowserContract.Visits.CONTENT_URI);
+
+ BrowserProvider provider = new BrowserProvider();
+ try {
+ provider.onCreate();
+ ShadowContentResolver.registerProvider(BrowserContract.AUTHORITY, new DelegatingTestContentProvider(provider));
+
+ final ShadowContentResolver cr = new ShadowContentResolver();
+ ContentProviderClient historyClient = cr.acquireContentProviderClient(BrowserContractHelpers.HISTORY_CONTENT_URI);
+ ContentProviderClient visitsClient = cr.acquireContentProviderClient(BrowserContractHelpers.VISITS_CONTENT_URI);
+
+ ContentValues historyItem = new ContentValues();
+ historyItem.put(BrowserContract.History.URL, "https://www.mozilla.org");
+ historyItem.put(BrowserContract.History.GUID, "testGUID");
+ historyClient.insert(historyTestUri, historyItem);
+
+ Long baseDate = System.currentTimeMillis();
+ for (int i = 0; i < 30; i++) {
+ ContentValues visitItem = new ContentValues();
+ visitItem.put(BrowserContract.Visits.HISTORY_GUID, "testGUID");
+ visitItem.put(BrowserContract.Visits.DATE_VISITED, baseDate - i * 100);
+ visitItem.put(BrowserContract.Visits.VISIT_TYPE, 1);
+ visitItem.put(BrowserContract.Visits.IS_LOCAL, 1);
+ visitsClient.insert(visitsTestUri, visitItem);
+ }
+
+ // test that limit worked, that sorting is correct, and that both date and type are present
+ JSONArray recentVisits = VisitsHelper.getRecentHistoryVisitsForGUID(visitsClient, "testGUID", 10);
+ Assert.assertEquals(10, recentVisits.size());
+ for (int i = 0; i < recentVisits.size(); i++) {
+ JSONObject v = (JSONObject) recentVisits.get(i);
+ Long date = (Long) v.get("date");
+ Long type = (Long) v.get("type");
+ Assert.assertEquals(Long.valueOf(baseDate - i * 100), date);
+ Assert.assertEquals(Long.valueOf(1), type);
+ }
+ } finally {
+ provider.shutdown();
+ }
+ }
+
+ @Test
+ public void testGetVisitContentValues() throws Exception {
+ JSONObject visit = new JSONObject();
+ Long date = Long.valueOf(123432552344l);
+ visit.put("date", date);
+ visit.put("type", Long.valueOf(2));
+
+ ContentValues cv = VisitsHelper.getVisitContentValues("testGUID", visit, true);
+ assertTrue(cv.containsKey(BrowserContract.Visits.VISIT_TYPE));
+ assertTrue(cv.containsKey(BrowserContract.Visits.DATE_VISITED));
+ assertTrue(cv.containsKey(BrowserContract.Visits.HISTORY_GUID));
+ assertTrue(cv.containsKey(BrowserContract.Visits.IS_LOCAL));
+ assertEquals(4, cv.size());
+
+ assertEquals(date, cv.getAsLong(BrowserContract.Visits.DATE_VISITED));
+ assertEquals(Long.valueOf(2), cv.getAsLong(BrowserContract.Visits.VISIT_TYPE));
+ assertEquals("testGUID", cv.getAsString(BrowserContract.Visits.HISTORY_GUID));
+ assertEquals(Integer.valueOf(1), cv.getAsInteger(BrowserContract.Visits.IS_LOCAL));
+
+ cv = VisitsHelper.getVisitContentValues("testGUID", visit, false);
+ assertEquals(Integer.valueOf(0), cv.getAsInteger(BrowserContract.Visits.IS_LOCAL));
+
+ try {
+ JSONObject visit2 = new JSONObject();
+ visit.put("date", date);
+ VisitsHelper.getVisitContentValues("testGUID", visit2, false);
+ assertTrue("Must check that visit type key is present", false);
+ } catch (IllegalArgumentException e) {}
+
+ try {
+ JSONObject visit3 = new JSONObject();
+ visit.put("type", Long.valueOf(2));
+ VisitsHelper.getVisitContentValues("testGUID", visit3, false);
+ assertTrue("Must check that visit date key is present", false);
+ } catch (IllegalArgumentException e) {}
+
+ try {
+ JSONObject visit4 = new JSONObject();
+ VisitsHelper.getVisitContentValues("testGUID", visit4, false);
+ assertTrue("Must check that visit type and date keys are present", false);
+ } catch (IllegalArgumentException e) {}
+ }
+
+ private Uri testUri(Uri baseUri) {
+ return baseUri.buildUpon().appendQueryParameter(BrowserContract.PARAM_IS_TEST, "1").build();
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/test/TestBookmarksInsertionManager.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/test/TestBookmarksInsertionManager.java
new file mode 100644
index 000000000..cbf5c37d3
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/test/TestBookmarksInsertionManager.java
@@ -0,0 +1,221 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.android.test;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.android.BookmarksInsertionManager;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestBookmarksInsertionManager {
+ public BookmarksInsertionManager manager;
+ public ArrayList<String[]> insertions;
+
+ @Before
+ public void setUp() {
+ insertions = new ArrayList<String[]>();
+ Set<String> writtenFolders = new HashSet<String>();
+ writtenFolders.add("mobile");
+
+ BookmarksInsertionManager.BookmarkInserter inserter = new BookmarksInsertionManager.BookmarkInserter() {
+ @Override
+ public boolean insertFolder(BookmarkRecord record) {
+ if (record.guid == "fail") {
+ return false;
+ }
+ Logger.debug(BookmarksInsertionManager.LOG_TAG, "Inserted folder (" + record.guid + ").");
+ insertions.add(new String[] { record.guid });
+ return true;
+ }
+
+ @Override
+ public void bulkInsertNonFolders(Collection<BookmarkRecord> records) {
+ ArrayList<String> guids = new ArrayList<String>();
+ for (BookmarkRecord record : records) {
+ guids.add(record.guid);
+ }
+ String[] guidList = guids.toArray(new String[guids.size()]);
+ insertions.add(guidList);
+ Logger.debug(BookmarksInsertionManager.LOG_TAG, "Inserted non-folders (" + Utils.toCommaSeparatedString(guids) + ").");
+ }
+ };
+ manager = new BookmarksInsertionManager(3, writtenFolders, inserter);
+ BookmarksInsertionManager.DEBUG = true;
+ }
+
+ protected static BookmarkRecord bookmark(String guid, String parent) {
+ BookmarkRecord bookmark = new BookmarkRecord(guid);
+ bookmark.type = "bookmark";
+ bookmark.parentID = parent;
+ return bookmark;
+ }
+
+ protected static BookmarkRecord folder(String guid, String parent) {
+ BookmarkRecord bookmark = new BookmarkRecord(guid);
+ bookmark.type = "folder";
+ bookmark.parentID = parent;
+ return bookmark;
+ }
+
+ @Test
+ public void testChildrenBeforeFolder() {
+ BookmarkRecord folder = folder("folder", "mobile");
+ BookmarkRecord child1 = bookmark("child1", "folder");
+ BookmarkRecord child2 = bookmark("child2", "folder");
+
+ manager.enqueueRecord(child1);
+ assertTrue(insertions.isEmpty());
+ manager.enqueueRecord(child2);
+ assertTrue(insertions.isEmpty());
+ manager.enqueueRecord(folder);
+ assertEquals(1, insertions.size());
+ manager.finishUp();
+ assertTrue(manager.isClear());
+ assertEquals(2, insertions.size());
+ assertArrayEquals(new String[] { "folder" }, insertions.get(0));
+ assertArrayEquals(new String[] { "child1", "child2" }, insertions.get(1));
+ }
+
+ @Test
+ public void testChildAfterFolder() {
+ BookmarkRecord folder = folder("folder", "mobile");
+ BookmarkRecord child1 = bookmark("child1", "folder");
+ BookmarkRecord child2 = bookmark("child2", "folder");
+
+ manager.enqueueRecord(child1);
+ assertTrue(insertions.isEmpty());
+ manager.enqueueRecord(folder);
+ assertEquals(1, insertions.size());
+ manager.enqueueRecord(child2);
+ assertEquals(1, insertions.size());
+ manager.finishUp();
+ assertTrue(manager.isClear());
+ assertEquals(2, insertions.size());
+ assertArrayEquals(new String[] { "folder" }, insertions.get(0));
+ assertArrayEquals(new String[] { "child1", "child2" }, insertions.get(1));
+ }
+
+ @Test
+ public void testFolderAfterFolder() {
+ manager.enqueueRecord(bookmark("child1", "folder1"));
+ assertEquals(0, insertions.size());
+ manager.enqueueRecord(folder("folder1", "mobile"));
+ assertEquals(1, insertions.size());
+ manager.enqueueRecord(bookmark("child2", "folder2"));
+ assertEquals(1, insertions.size());
+ manager.enqueueRecord(folder("folder2", "folder1"));
+ assertEquals(2, insertions.size());
+ manager.enqueueRecord(bookmark("child3", "folder1"));
+ manager.enqueueRecord(bookmark("child4", "folder2"));
+ assertEquals(3, insertions.size());
+
+ manager.finishUp();
+ assertTrue(manager.isClear());
+ assertEquals(4, insertions.size());
+ assertArrayEquals(new String[] { "folder1" }, insertions.get(0));
+ assertArrayEquals(new String[] { "folder2" }, insertions.get(1));
+ assertArrayEquals(new String[] { "child1", "child2", "child3" }, insertions.get(2));
+ assertArrayEquals(new String[] { "child4" }, insertions.get(3));
+ }
+
+ @Test
+ public void testFolderRecursion() {
+ manager.enqueueRecord(folder("1", "mobile"));
+ manager.enqueueRecord(folder("2", "1"));
+ assertEquals(2, insertions.size());
+ manager.enqueueRecord(bookmark("3a", "3"));
+ manager.enqueueRecord(bookmark("3b", "3"));
+ manager.enqueueRecord(bookmark("3c", "3"));
+ manager.enqueueRecord(bookmark("4a", "4"));
+ manager.enqueueRecord(bookmark("4b", "4"));
+ manager.enqueueRecord(bookmark("4c", "4"));
+ assertEquals(2, insertions.size());
+ manager.enqueueRecord(folder("3", "2"));
+ assertEquals(4, insertions.size());
+ manager.enqueueRecord(folder("4", "2"));
+ assertEquals(6, insertions.size());
+
+ assertTrue(manager.isClear());
+ manager.finishUp();
+ assertTrue(manager.isClear());
+ // Folders in order.
+ assertArrayEquals(new String[] { "1" }, insertions.get(0));
+ assertArrayEquals(new String[] { "2" }, insertions.get(1));
+ assertArrayEquals(new String[] { "3" }, insertions.get(2));
+ // Then children in batches of 3.
+ assertArrayEquals(new String[] { "3a", "3b", "3c" }, insertions.get(3));
+ // Then last folder.
+ assertArrayEquals(new String[] { "4" }, insertions.get(4));
+ assertArrayEquals(new String[] { "4a", "4b", "4c" }, insertions.get(5));
+ }
+
+ @Test
+ public void testFailedFolderInsertion() {
+ manager.enqueueRecord(bookmark("failA", "fail"));
+ manager.enqueueRecord(bookmark("failB", "fail"));
+ assertEquals(0, insertions.size());
+ manager.enqueueRecord(folder("fail", "mobile"));
+ assertEquals(0, insertions.size());
+ manager.enqueueRecord(bookmark("failC", "fail"));
+ assertEquals(0, insertions.size());
+ manager.finishUp(); // Children inserted at the end; they will be treated as orphans.
+ assertTrue(manager.isClear());
+ assertEquals(1, insertions.size());
+ assertArrayEquals(new String[] { "failA", "failB", "failC" }, insertions.get(0));
+ }
+
+ @Test
+ public void testIncrementalFlush() {
+ manager.enqueueRecord(bookmark("a", "1"));
+ manager.enqueueRecord(bookmark("b", "1"));
+ manager.enqueueRecord(folder("1", "mobile"));
+ assertEquals(1, insertions.size());
+ manager.enqueueRecord(bookmark("c", "1"));
+ assertEquals(2, insertions.size());
+ manager.enqueueRecord(bookmark("d", "1"));
+ manager.enqueueRecord(bookmark("e", "1"));
+ manager.enqueueRecord(bookmark("f", "1"));
+ assertEquals(3, insertions.size());
+ manager.enqueueRecord(bookmark("g", "1")); // Start of new batch.
+ assertEquals(3, insertions.size());
+ manager.finishUp(); // Children inserted at the end; they will be treated as orphans.
+ assertTrue(manager.isClear());
+ assertEquals(4, insertions.size());
+ assertArrayEquals(new String[] { "1" }, insertions.get(0));
+ assertArrayEquals(new String[] { "a", "b", "c"}, insertions.get(1));
+ assertArrayEquals(new String[] { "d", "e", "f"}, insertions.get(2));
+ assertArrayEquals(new String[] { "g" }, insertions.get(3));
+ }
+
+ @Test
+ public void testFinishUp() {
+ manager.enqueueRecord(bookmark("a", "1"));
+ manager.enqueueRecord(bookmark("b", "1"));
+ manager.enqueueRecord(folder("2", "1"));
+ manager.enqueueRecord(bookmark("c", "1"));
+ manager.enqueueRecord(bookmark("d", "1"));
+ manager.enqueueRecord(folder("3", "1"));
+ assertEquals(0, insertions.size());
+ manager.finishUp(); // Children inserted at the end; they will be treated as orphans.
+ assertTrue(manager.isClear());
+ assertEquals(3, insertions.size());
+ assertArrayEquals(new String[] { "2" }, insertions.get(0));
+ assertArrayEquals(new String[] { "3" }, insertions.get(1));
+ assertArrayEquals(new String[] { "a", "b", "c", "d" }, insertions.get(2)); // Last insertion could be big.
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/domain/TestClientRecord.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/domain/TestClientRecord.java
new file mode 100644
index 000000000..790d47620
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/domain/TestClientRecord.java
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.domain;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.Utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestClientRecord {
+
+ @Test
+ public void testEnsureDefaults() {
+ // Ensure defaults.
+ ClientRecord record = new ClientRecord();
+ assertEquals(ClientRecord.COLLECTION_NAME, record.collection);
+ assertEquals(0, record.lastModified);
+ assertEquals(false, record.deleted);
+ assertEquals("Default Name", record.name);
+ assertEquals(ClientRecord.CLIENT_TYPE, record.type);
+ assertTrue(null == record.os);
+ assertTrue(null == record.device);
+ assertTrue(null == record.application);
+ assertTrue(null == record.appPackage);
+ assertTrue(null == record.formfactor);
+ }
+
+ @Test
+ public void testGetPayload() {
+ // Test ClientRecord.getPayload().
+ ClientRecord record = new ClientRecord();
+ CryptoRecord cryptoRecord = record.getEnvelope();
+ assertEquals(record.guid, cryptoRecord.payload.get("id"));
+ assertEquals(null, cryptoRecord.payload.get("collection"));
+ assertEquals(null, cryptoRecord.payload.get("lastModified"));
+ assertEquals(null, cryptoRecord.payload.get("deleted"));
+ assertEquals(null, cryptoRecord.payload.get("version"));
+ assertEquals(record.name, cryptoRecord.payload.get("name"));
+ assertEquals(record.type, cryptoRecord.payload.get("type"));
+ }
+
+ @Test
+ public void testInitFromPayload() {
+ // Test ClientRecord.initFromPayload() in ClientRecordFactory.
+ ClientRecord record1 = new ClientRecord();
+ CryptoRecord cryptoRecord = record1.getEnvelope();
+ ClientRecordFactory factory = new ClientRecordFactory();
+ ClientRecord record2 = (ClientRecord) factory.createRecord(cryptoRecord);
+ assertEquals(cryptoRecord.payload.get("id"), record2.guid);
+ assertEquals(ClientRecord.COLLECTION_NAME, record2.collection);
+ assertEquals(0, record2.lastModified);
+ assertEquals(false, record2.deleted);
+ assertEquals(cryptoRecord.payload.get("name"), record2.name);
+ assertEquals(cryptoRecord.payload.get("type"), record2.type);
+ }
+
+ @Test
+ public void testCopyWithIDs() {
+ // Test ClientRecord.copyWithIDs.
+ ClientRecord record1 = new ClientRecord();
+ record1.version = "20";
+ String newGUID = Utils.generateGuid();
+ ClientRecord record2 = (ClientRecord) record1.copyWithIDs(newGUID, 0);
+ assertEquals(newGUID, record2.guid);
+ assertEquals(0, record2.androidID);
+ assertEquals(record1.collection, record2.collection);
+ assertEquals(record1.lastModified, record2.lastModified);
+ assertEquals(record1.deleted, record2.deleted);
+ assertEquals(record1.name, record2.name);
+ assertEquals(record1.type, record2.type);
+ assertEquals(record1.version, record2.version);
+ }
+
+ @Test
+ public void testEquals() {
+ // Test ClientRecord.equals().
+ ClientRecord record1 = new ClientRecord();
+ ClientRecord record2 = new ClientRecord();
+ record2.guid = record1.guid;
+ record2.version = "20";
+ record1.version = null;
+
+ ClientRecord record3 = new ClientRecord(Utils.generateGuid());
+ record3.name = "New Name";
+
+ ClientRecord record4 = new ClientRecord(Utils.generateGuid());
+ record4.name = ClientRecord.DEFAULT_CLIENT_NAME;
+ record4.type = "desktop";
+
+ assertTrue(record2.equals(record1));
+ assertFalse(record3.equals(record1));
+ assertFalse(record3.equals(record2));
+ assertFalse(record4.equals(record1));
+ assertFalse(record4.equals(record2));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/domain/test/TestFormHistoryRecord.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/domain/test/TestFormHistoryRecord.java
new file mode 100644
index 000000000..c0682e90e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/domain/test/TestFormHistoryRecord.java
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.domain.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.domain.FormHistoryRecord;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestFormHistoryRecord {
+ public static FormHistoryRecord withIdFieldNameAndValue(long id, String fieldName, String value) {
+ FormHistoryRecord fr = new FormHistoryRecord();
+ fr.androidID = id;
+ fr.fieldName = fieldName;
+ fr.fieldValue = value;
+
+ return fr;
+ }
+
+ @Test
+ public void testCollection() {
+ FormHistoryRecord fr = new FormHistoryRecord();
+ assertEquals("forms", fr.collection);
+ }
+
+ @Test
+ public void testGetPayload() {
+ FormHistoryRecord fr = withIdFieldNameAndValue(0, "username", "aUsername");
+ CryptoRecord rec = fr.getEnvelope();
+ assertEquals("username", rec.payload.get("name"));
+ assertEquals("aUsername", rec.payload.get("value"));
+ }
+
+ @Test
+ public void testCopyWithIDs() {
+ FormHistoryRecord fr = withIdFieldNameAndValue(0, "username", "aUsername");
+ String guid = Utils.generateGuid();
+ FormHistoryRecord fr2 = (FormHistoryRecord)fr.copyWithIDs(guid, 9999);
+ assertEquals(guid, fr2.guid);
+ assertEquals(9999, fr2.androidID);
+ assertEquals(fr.fieldName, fr2.fieldName);
+ assertEquals(fr.fieldValue, fr2.fieldValue);
+ }
+
+ @Test
+ public void testEquals() {
+ FormHistoryRecord fr1a = withIdFieldNameAndValue(0, "username1", "Alice");
+ FormHistoryRecord fr1b = withIdFieldNameAndValue(0, "username1", "Bob");
+ FormHistoryRecord fr2a = withIdFieldNameAndValue(0, "username2", "Alice");
+ FormHistoryRecord fr2b = withIdFieldNameAndValue(0, "username2", "Bob");
+
+ assertFalse(fr1a.equals(fr1b));
+ assertFalse(fr1a.equals(fr2a));
+ assertFalse(fr1a.equals(fr2b));
+ assertFalse(fr1b.equals(fr2a));
+ assertFalse(fr1b.equals(fr2b));
+ assertFalse(fr2a.equals(fr2b));
+
+ assertFalse(fr1a.equals(withIdFieldNameAndValue(fr1a.androidID, fr1a.fieldName, fr1b.fieldValue)));
+ assertFalse(fr1a.equals(fr1a.copyWithIDs(fr2a.guid, 9999)));
+ assertTrue(fr1a.equals(fr1a));
+ }
+
+ @Test
+ public void testEqualsForDeleted() {
+ FormHistoryRecord fr1 = withIdFieldNameAndValue(0, "username1", "Alice");
+ FormHistoryRecord fr2 = (FormHistoryRecord)fr1.copyWithIDs(fr1.guid, fr1.androidID);
+ assertTrue(fr1.equals(fr2));
+ fr1.deleted = true;
+ assertFalse(fr1.equals(fr2));
+ fr2.deleted = true;
+ assertTrue(fr1.equals(fr2));
+ FormHistoryRecord fr3 = (FormHistoryRecord)fr2.copyWithIDs(Utils.generateGuid(), 9999);
+ assertFalse(fr2.equals(fr3));
+ }
+
+ @Test
+ public void testTTL() {
+ FormHistoryRecord fr = withIdFieldNameAndValue(0, "username", "aUsername");
+ assertEquals(FormHistoryRecord.FORMS_TTL, fr.ttl);
+ CryptoRecord rec = fr.getEnvelope();
+ assertEquals(FormHistoryRecord.FORMS_TTL, rec.ttl);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegateTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegateTest.java
new file mode 100644
index 000000000..da2bbac18
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegateTest.java
@@ -0,0 +1,186 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy 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.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.InfoConfiguration;
+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 org.mozilla.gecko.sync.repositories.domain.Record;
+
+import java.net.URI;
+import java.util.concurrent.ExecutorService;
+
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+import ch.boye.httpclientandroidlib.message.BasicStatusLine;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class BatchingDownloaderDelegateTest {
+ private Server11Repository server11Repository;
+ private Server11RepositorySession repositorySession;
+ private MockDownloader mockDownloader;
+ private String DEFAULT_COLLECTION_URL = "http://dummy.url/";
+
+ class MockDownloader extends BatchingDownloader {
+ public boolean isSuccess = false;
+ public boolean isFetched = false;
+ public boolean isFailure = false;
+ public Exception ex;
+
+ public MockDownloader(Server11Repository repository, Server11RepositorySession repositorySession) {
+ super(repository, repositorySession);
+ }
+
+ @Override
+ public void onFetchCompleted(SyncStorageResponse response,
+ final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate,
+ final SyncStorageCollectionRequest request,
+ long l, long newerTimestamp, boolean full, String sort, String ids) {
+ this.isSuccess = true;
+ }
+
+ @Override
+ public void onFetchFailed(final Exception ex,
+ final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate,
+ final SyncStorageCollectionRequest request) {
+ this.isFailure = true;
+ this.ex = ex;
+ }
+
+ @Override
+ public void onFetchedRecord(CryptoRecord record,
+ RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) {
+ this.isFetched = true;
+ }
+ }
+
+ class SimpleSessionFetchRecordsDelegate implements RepositorySessionFetchRecordsDelegate {
+ @Override
+ public void onFetchFailed(Exception ex, Record record) {
+
+ }
+
+ @Override
+ public void onFetchedRecord(Record record) {
+
+ }
+
+ @Override
+ public void onFetchCompleted(long fetchEnd) {
+
+ }
+
+ @Override
+ public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor) {
+ return null;
+ }
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ server11Repository = new Server11Repository(
+ "dummyCollection",
+ DEFAULT_COLLECTION_URL,
+ null,
+ new InfoCollections(),
+ new InfoConfiguration());
+ repositorySession = new Server11RepositorySession(server11Repository);
+ mockDownloader = new MockDownloader(server11Repository, repositorySession);
+ }
+
+ @Test
+ public void testIfUnmodifiedSince() throws Exception {
+ BatchingDownloader downloader = new BatchingDownloader(server11Repository, repositorySession);
+ RepositorySessionFetchRecordsDelegate delegate = new SimpleSessionFetchRecordsDelegate();
+ BatchingDownloaderDelegate downloaderDelegate = new BatchingDownloaderDelegate(downloader, delegate,
+ new SyncStorageCollectionRequest(new URI(DEFAULT_COLLECTION_URL)), 0, 0, true, null, null);
+ String lastModified = "12345678";
+ SyncStorageResponse response = makeSyncStorageResponse(200, lastModified);
+ downloaderDelegate.handleRequestSuccess(response);
+ assertEquals(lastModified, downloaderDelegate.ifUnmodifiedSince());
+ }
+
+ @Test
+ public void testSuccess() throws Exception {
+ BatchingDownloaderDelegate downloaderDelegate = new BatchingDownloaderDelegate(mockDownloader, null,
+ new SyncStorageCollectionRequest(new URI(DEFAULT_COLLECTION_URL)), 0, 0, true, null, null);
+ SyncStorageResponse response = makeSyncStorageResponse(200, "12345678");
+ downloaderDelegate.handleRequestSuccess(response);
+ assertTrue(mockDownloader.isSuccess);
+ assertFalse(mockDownloader.isFailure);
+ assertFalse(mockDownloader.isFetched);
+ }
+
+ @Test
+ public void testFailureMissingLMHeader() throws Exception {
+ BatchingDownloaderDelegate downloaderDelegate = new BatchingDownloaderDelegate(mockDownloader, null,
+ new SyncStorageCollectionRequest(new URI(DEFAULT_COLLECTION_URL)), 0, 0, true, null, null);
+ SyncStorageResponse response = makeSyncStorageResponse(200, null);
+ downloaderDelegate.handleRequestSuccess(response);
+ assertTrue(mockDownloader.isFailure);
+ assertEquals(IllegalStateException.class, mockDownloader.ex.getClass());
+ assertFalse(mockDownloader.isSuccess);
+ assertFalse(mockDownloader.isFetched);
+ }
+
+ @Test
+ public void testFailureHTTPException() throws Exception {
+ BatchingDownloaderDelegate downloaderDelegate = new BatchingDownloaderDelegate(mockDownloader, null,
+ new SyncStorageCollectionRequest(new URI(DEFAULT_COLLECTION_URL)), 0, 0, true, null, null);
+ SyncStorageResponse response = makeSyncStorageResponse(400, null);
+ downloaderDelegate.handleRequestFailure(response);
+ assertTrue(mockDownloader.isFailure);
+ assertEquals(HTTPFailureException.class, mockDownloader.ex.getClass());
+ assertFalse(mockDownloader.isSuccess);
+ assertFalse(mockDownloader.isFetched);
+ }
+
+ @Test
+ public void testFailureRequestError() throws Exception {
+ BatchingDownloaderDelegate downloaderDelegate = new BatchingDownloaderDelegate(mockDownloader, null,
+ new SyncStorageCollectionRequest(new URI(DEFAULT_COLLECTION_URL)), 0, 0, true, null, null);
+ downloaderDelegate.handleRequestError(new ClientProtocolException());
+ assertTrue(mockDownloader.isFailure);
+ assertEquals(ClientProtocolException.class, mockDownloader.ex.getClass());
+ assertFalse(mockDownloader.isSuccess);
+ assertFalse(mockDownloader.isFetched);
+ }
+
+ @Test
+ public void testFetchRecord() throws Exception {
+ BatchingDownloaderDelegate downloaderDelegate = new BatchingDownloaderDelegate(mockDownloader, null,
+ new SyncStorageCollectionRequest(new URI(DEFAULT_COLLECTION_URL)), 0, 0, true, null, null);
+ CryptoRecord record = new CryptoRecord();
+ downloaderDelegate.handleWBO(record);
+ assertTrue(mockDownloader.isFetched);
+ assertFalse(mockDownloader.isSuccess);
+ assertFalse(mockDownloader.isFailure);
+ }
+
+ private SyncStorageResponse makeSyncStorageResponse(int code, String lastModified) {
+ BasicHttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), code, null));
+
+ if (lastModified != null) {
+ response.addHeader(SyncResponse.X_LAST_MODIFIED, lastModified);
+ }
+
+ return new SyncStorageResponse(response);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderTest.java
new file mode 100644
index 000000000..fbbd9cae9
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderTest.java
@@ -0,0 +1,543 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy 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.NonNull;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.InfoConfiguration;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+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.Repository;
+import org.mozilla.gecko.sync.repositories.Server11Repository;
+import org.mozilla.gecko.sync.repositories.Server11RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.concurrent.ExecutorService;
+
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+import ch.boye.httpclientandroidlib.message.BasicStatusLine;
+
+import static org.junit.Assert.*;
+import static org.junit.Assert.assertEquals;
+
+@RunWith(TestRunner.class)
+public class BatchingDownloaderTest {
+ private MockSever11Repository serverRepository;
+ private Server11RepositorySession repositorySession;
+ private MockSessionFetchRecordsDelegate sessionFetchRecordsDelegate;
+ private MockDownloader mockDownloader;
+ private String DEFAULT_COLLECTION_NAME = "dummyCollection";
+ private String DEFAULT_COLLECTION_URL = "http://dummy.url/";
+ private long DEFAULT_NEWER = 1;
+ private String DEFAULT_SORT = "index";
+ private String DEFAULT_IDS = "1";
+ private String DEFAULT_LMHEADER = "12345678";
+
+ class MockSessionFetchRecordsDelegate implements RepositorySessionFetchRecordsDelegate {
+ public boolean isFailure;
+ public boolean isFetched;
+ public boolean isSuccess;
+ public Exception ex;
+ public Record record;
+
+ @Override
+ public void onFetchFailed(Exception ex, Record record) {
+ this.isFailure = true;
+ this.ex = ex;
+ this.record = record;
+ }
+
+ @Override
+ public void onFetchedRecord(Record record) {
+ this.isFetched = true;
+ this.record = record;
+ }
+
+ @Override
+ public void onFetchCompleted(long fetchEnd) {
+ this.isSuccess = true;
+ }
+
+ @Override
+ public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor) {
+ return null;
+ }
+ }
+
+ class MockRequest extends SyncStorageCollectionRequest {
+
+ public MockRequest(URI uri) {
+ super(uri);
+ }
+
+ @Override
+ public void get() {
+
+ }
+ }
+
+ class MockDownloader extends BatchingDownloader {
+ public long newer;
+ public long limit;
+ public boolean full;
+ public String sort;
+ public String ids;
+ public String offset;
+ public boolean abort;
+
+ public MockDownloader(Server11Repository repository, Server11RepositorySession repositorySession) {
+ super(repository, repositorySession);
+ }
+
+ @Override
+ public void fetchWithParameters(long newer,
+ long batchLimit,
+ boolean full,
+ String sort,
+ String ids,
+ SyncStorageCollectionRequest request,
+ RepositorySessionFetchRecordsDelegate fetchRecordsDelegate)
+ throws UnsupportedEncodingException, URISyntaxException {
+ this.newer = newer;
+ this.limit = batchLimit;
+ this.full = full;
+ this.sort = sort;
+ this.ids = ids;
+ MockRequest mockRequest = new MockRequest(new URI(DEFAULT_COLLECTION_URL));
+ super.fetchWithParameters(newer, batchLimit, full, sort, ids, mockRequest, fetchRecordsDelegate);
+ }
+
+ @Override
+ public void abortRequests() {
+ this.abort = true;
+ }
+
+ @Override
+ public SyncStorageCollectionRequest makeSyncStorageCollectionRequest(long newer,
+ long batchLimit,
+ boolean full,
+ String sort,
+ String ids,
+ String offset)
+ throws URISyntaxException, UnsupportedEncodingException {
+ this.offset = offset;
+ return super.makeSyncStorageCollectionRequest(newer, batchLimit, full, sort, ids, offset);
+ }
+ }
+
+ class MockSever11Repository extends Server11Repository {
+ public MockSever11Repository(@NonNull String collection, @NonNull String storageURL,
+ AuthHeaderProvider authHeaderProvider, @NonNull InfoCollections infoCollections,
+ @NonNull InfoConfiguration infoConfiguration) throws URISyntaxException {
+ super(collection, storageURL, authHeaderProvider, infoCollections, infoConfiguration);
+ }
+
+ @Override
+ public long getDefaultTotalLimit() {
+ return 200;
+ }
+ }
+
+ class MockRepositorySession extends Server11RepositorySession {
+ public boolean abort;
+
+ public MockRepositorySession(Repository repository) {
+ super(repository);
+ }
+
+ @Override
+ public void abort() {
+ this.abort = true;
+ }
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ sessionFetchRecordsDelegate = new MockSessionFetchRecordsDelegate();
+
+ serverRepository = new MockSever11Repository(DEFAULT_COLLECTION_NAME, DEFAULT_COLLECTION_URL, null,
+ new InfoCollections(), new InfoConfiguration());
+ repositorySession = new Server11RepositorySession(serverRepository);
+ mockDownloader = new MockDownloader(serverRepository, repositorySession);
+ }
+
+ @Test
+ public void testFlattenId() {
+ String[] emptyGuid = new String[]{};
+ String flatten = BatchingDownloader.flattenIDs(emptyGuid);
+ assertEquals("", flatten);
+
+ String guid0 = "123456789abc";
+ String[] singleGuid = new String[1];
+ singleGuid[0] = guid0;
+ flatten = BatchingDownloader.flattenIDs(singleGuid);
+ assertEquals("123456789abc", flatten);
+
+ String guid1 = "456789abc";
+ String guid2 = "789abc";
+ String[] multiGuid = new String[3];
+ multiGuid[0] = guid0;
+ multiGuid[1] = guid1;
+ multiGuid[2] = guid2;
+ flatten = BatchingDownloader.flattenIDs(multiGuid);
+ assertEquals("123456789abc,456789abc,789abc", flatten);
+ }
+
+ @Test
+ public void testEncodeParam() throws Exception {
+ String param = "123&123";
+ String encodedParam = mockDownloader.encodeParam(param);
+ assertEquals("123%26123", encodedParam);
+ }
+
+ @Test(expected=IllegalArgumentException.class)
+ public void testOverTotalLimit() throws Exception {
+ // Per-batch limits exceed total.
+ Server11Repository repository = new Server11Repository(DEFAULT_COLLECTION_NAME, DEFAULT_COLLECTION_URL,
+ null, new InfoCollections(), new InfoConfiguration()) {
+ @Override
+ public long getDefaultTotalLimit() {
+ return 100;
+ }
+ @Override
+ public long getDefaultBatchLimit() {
+ return 200;
+ }
+ };
+ MockDownloader mockDownloader = new MockDownloader(repository, repositorySession);
+
+ assertNull(mockDownloader.getLastModified());
+ mockDownloader.fetchSince(DEFAULT_NEWER, sessionFetchRecordsDelegate);
+ }
+
+ @Test
+ public void testTotalLimit() throws Exception {
+ // Total and per-batch limits are the same.
+ Server11Repository repository = new Server11Repository(DEFAULT_COLLECTION_NAME, DEFAULT_COLLECTION_URL,
+ null, new InfoCollections(), new InfoConfiguration()) {
+ @Override
+ public long getDefaultTotalLimit() {
+ return 100;
+ }
+ @Override
+ public long getDefaultBatchLimit() {
+ return 100;
+ }
+ };
+ MockDownloader mockDownloader = new MockDownloader(repository, repositorySession);
+
+ assertNull(mockDownloader.getLastModified());
+ mockDownloader.fetchSince(DEFAULT_NEWER, sessionFetchRecordsDelegate);
+
+ SyncStorageResponse response = makeSyncStorageResponse(200, DEFAULT_LMHEADER, "100", "100");
+ SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(new URI(DEFAULT_COLLECTION_URL));
+ long limit = repository.getDefaultBatchLimit();
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request,
+ DEFAULT_NEWER, limit, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ assertEquals(DEFAULT_LMHEADER, mockDownloader.getLastModified());
+ assertTrue(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertFalse(sessionFetchRecordsDelegate.isFailure);
+ }
+
+ @Test
+ public void testOverHalfOfTotalLimit() throws Exception {
+ // Per-batch limit is just a bit lower than total.
+ Server11Repository repository = new Server11Repository(DEFAULT_COLLECTION_NAME, DEFAULT_COLLECTION_URL,
+ null, new InfoCollections(), new InfoConfiguration()) {
+ @Override
+ public long getDefaultTotalLimit() {
+ return 100;
+ }
+ @Override
+ public long getDefaultBatchLimit() {
+ return 75;
+ }
+ };
+ MockDownloader mockDownloader = new MockDownloader(repository, repositorySession);
+
+ assertNull(mockDownloader.getLastModified());
+ mockDownloader.fetchSince(DEFAULT_NEWER, sessionFetchRecordsDelegate);
+
+ String offsetHeader = "75";
+ String recordsHeader = "75";
+ SyncStorageResponse response = makeSyncStorageResponse(200, DEFAULT_LMHEADER, offsetHeader, recordsHeader);
+ SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(new URI(DEFAULT_COLLECTION_URL));
+ long limit = repository.getDefaultBatchLimit();
+
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
+ limit, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ assertEquals(DEFAULT_LMHEADER, mockDownloader.getLastModified());
+ // Verify the same parameters are used in the next fetch.
+ assertSameParameters(mockDownloader, limit);
+ assertEquals(offsetHeader, mockDownloader.offset);
+ assertFalse(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertFalse(sessionFetchRecordsDelegate.isFailure);
+
+ // The next batch, we still have an offset token but we complete our fetch since we have reached the total limit.
+ offsetHeader = "150";
+ response = makeSyncStorageResponse(200, DEFAULT_LMHEADER, offsetHeader, recordsHeader);
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
+ limit, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ assertEquals(DEFAULT_LMHEADER, mockDownloader.getLastModified());
+ assertTrue(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertFalse(sessionFetchRecordsDelegate.isFailure);
+ }
+
+ @Test
+ public void testHalfOfTotalLimit() throws Exception {
+ // Per-batch limit is half of total.
+ Server11Repository repository = new Server11Repository(DEFAULT_COLLECTION_NAME, DEFAULT_COLLECTION_URL,
+ null, new InfoCollections(), new InfoConfiguration()) {
+ @Override
+ public long getDefaultTotalLimit() {
+ return 100;
+ }
+ @Override
+ public long getDefaultBatchLimit() {
+ return 50;
+ }
+ };
+ mockDownloader = new MockDownloader(repository, repositorySession);
+
+ assertNull(mockDownloader.getLastModified());
+ mockDownloader.fetchSince(DEFAULT_NEWER, sessionFetchRecordsDelegate);
+
+ String offsetHeader = "50";
+ String recordsHeader = "50";
+ SyncStorageResponse response = makeSyncStorageResponse(200, DEFAULT_LMHEADER, offsetHeader, recordsHeader);
+ SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(new URI(DEFAULT_COLLECTION_URL));
+ long limit = repository.getDefaultBatchLimit();
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
+ limit, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ assertEquals(DEFAULT_LMHEADER, mockDownloader.getLastModified());
+ // Verify the same parameters are used in the next fetch.
+ assertSameParameters(mockDownloader, limit);
+ assertEquals(offsetHeader, mockDownloader.offset);
+ assertFalse(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertFalse(sessionFetchRecordsDelegate.isFailure);
+
+ // The next batch, we still have an offset token but we complete our fetch since we have reached the total limit.
+ offsetHeader = "100";
+ response = makeSyncStorageResponse(200, DEFAULT_LMHEADER, offsetHeader, recordsHeader);
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
+ limit, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ assertEquals(DEFAULT_LMHEADER, mockDownloader.getLastModified());
+ assertTrue(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertFalse(sessionFetchRecordsDelegate.isFailure);
+ }
+
+ @Test
+ public void testFractionOfTotalLimit() throws Exception {
+ // Per-batch limit is a small fraction of the total.
+ Server11Repository repository = new Server11Repository(DEFAULT_COLLECTION_NAME, DEFAULT_COLLECTION_URL,
+ null, new InfoCollections(), new InfoConfiguration()) {
+ @Override
+ public long getDefaultTotalLimit() {
+ return 100;
+ }
+ @Override
+ public long getDefaultBatchLimit() {
+ return 25;
+ }
+ };
+ mockDownloader = new MockDownloader(repository, repositorySession);
+
+ assertNull(mockDownloader.getLastModified());
+ mockDownloader.fetchSince(DEFAULT_NEWER, sessionFetchRecordsDelegate);
+
+ String offsetHeader = "25";
+ String recordsHeader = "25";
+ SyncStorageResponse response = makeSyncStorageResponse(200, DEFAULT_LMHEADER, offsetHeader, recordsHeader);
+ SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(new URI(DEFAULT_COLLECTION_URL));
+ long limit = repository.getDefaultBatchLimit();
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
+ limit, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ assertEquals(DEFAULT_LMHEADER, mockDownloader.getLastModified());
+ // Verify the same parameters are used in the next fetch.
+ assertSameParameters(mockDownloader, limit);
+ assertEquals(offsetHeader, mockDownloader.offset);
+ assertFalse(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertFalse(sessionFetchRecordsDelegate.isFailure);
+
+ // The next batch, we still have an offset token and has not exceed the total limit.
+ offsetHeader = "50";
+ response = makeSyncStorageResponse(200, DEFAULT_LMHEADER, offsetHeader, recordsHeader);
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
+ limit, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ assertEquals(DEFAULT_LMHEADER, mockDownloader.getLastModified());
+ // Verify the same parameters are used in the next fetch.
+ assertSameParameters(mockDownloader, limit);
+ assertEquals(offsetHeader, mockDownloader.offset);
+ assertFalse(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertFalse(sessionFetchRecordsDelegate.isFailure);
+
+ // The next batch, we still have an offset token and has not exceed the total limit.
+ offsetHeader = "75";
+ response = makeSyncStorageResponse(200, DEFAULT_LMHEADER, offsetHeader, recordsHeader);
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
+ limit, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ assertEquals(DEFAULT_LMHEADER, mockDownloader.getLastModified());
+ // Verify the same parameters are used in the next fetch.
+ assertSameParameters(mockDownloader, limit);
+ assertEquals(offsetHeader, mockDownloader.offset);
+ assertFalse(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertFalse(sessionFetchRecordsDelegate.isFailure);
+
+ // The next batch, we still have an offset token but we complete our fetch since we have reached the total limit.
+ offsetHeader = "100";
+ response = makeSyncStorageResponse(200, DEFAULT_LMHEADER, offsetHeader, recordsHeader);
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
+ limit, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ assertEquals(DEFAULT_LMHEADER, mockDownloader.getLastModified());
+ assertTrue(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertFalse(sessionFetchRecordsDelegate.isFailure);
+ }
+
+ @Test
+ public void testFailureLMChangedMultiBatch() throws Exception {
+ assertNull(mockDownloader.getLastModified());
+
+ String lmHeader = "12345678";
+ String offsetHeader = "100";
+ SyncStorageResponse response = makeSyncStorageResponse(200, lmHeader, offsetHeader, null);
+ SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(new URI("http://dummy.url"));
+ long limit = 1;
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
+ limit, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ assertEquals(lmHeader, mockDownloader.getLastModified());
+ // Verify the same parameters are used in the next fetch.
+ assertEquals(DEFAULT_NEWER, mockDownloader.newer);
+ assertEquals(limit, mockDownloader.limit);
+ assertTrue(mockDownloader.full);
+ assertEquals(DEFAULT_SORT, mockDownloader.sort);
+ assertEquals(DEFAULT_IDS, mockDownloader.ids);
+ assertEquals(offsetHeader, mockDownloader.offset);
+ assertFalse(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertFalse(sessionFetchRecordsDelegate.isFailure);
+
+ // Last modified header somehow changed.
+ lmHeader = "10000000";
+ response = makeSyncStorageResponse(200, lmHeader, offsetHeader, null);
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
+ limit, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ assertNotEquals(lmHeader, mockDownloader.getLastModified());
+ assertTrue(mockDownloader.abort);
+ assertFalse(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertTrue(sessionFetchRecordsDelegate.isFailure);
+ }
+
+ @Test
+ public void testFailureMissingLMMultiBatch() throws Exception {
+ assertNull(mockDownloader.getLastModified());
+
+ String offsetHeader = "100";
+ SyncStorageResponse response = makeSyncStorageResponse(200, null, offsetHeader, null);
+ SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(new URI("http://dummy.url"));
+ mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
+ 1, true, DEFAULT_SORT, DEFAULT_IDS);
+
+ // Last modified header somehow missing from response.
+ assertNull(null, mockDownloader.getLastModified());
+ assertTrue(mockDownloader.abort);
+ assertFalse(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertTrue(sessionFetchRecordsDelegate.isFailure);
+ }
+
+ @Test
+ public void testFailureException() throws Exception {
+ Exception ex = new IllegalStateException();
+ SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(new URI("http://dummy.url"));
+ mockDownloader.onFetchFailed(ex, sessionFetchRecordsDelegate, request);
+
+ assertFalse(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFetched);
+ assertTrue(sessionFetchRecordsDelegate.isFailure);
+ assertEquals(ex.getClass(), sessionFetchRecordsDelegate.ex.getClass());
+ assertNull(sessionFetchRecordsDelegate.record);
+ }
+
+ @Test
+ public void testFetchRecord() {
+ CryptoRecord record = new CryptoRecord();
+ mockDownloader.onFetchedRecord(record, sessionFetchRecordsDelegate);
+
+ assertTrue(sessionFetchRecordsDelegate.isFetched);
+ assertFalse(sessionFetchRecordsDelegate.isSuccess);
+ assertFalse(sessionFetchRecordsDelegate.isFailure);
+ assertEquals(record, sessionFetchRecordsDelegate.record);
+ }
+
+ @Test
+ public void testAbortRequests() {
+ MockRepositorySession mockRepositorySession = new MockRepositorySession(serverRepository);
+ BatchingDownloader downloader = new BatchingDownloader(serverRepository, mockRepositorySession);
+ assertFalse(mockRepositorySession.abort);
+ downloader.abortRequests();
+ assertTrue(mockRepositorySession.abort);
+ }
+
+ private void assertSameParameters(MockDownloader mockDownloader, long limit) {
+ assertEquals(DEFAULT_NEWER, mockDownloader.newer);
+ assertEquals(limit, mockDownloader.limit);
+ assertTrue(mockDownloader.full);
+ assertEquals(DEFAULT_SORT, mockDownloader.sort);
+ assertEquals(DEFAULT_IDS, mockDownloader.ids);
+ }
+
+ private SyncStorageResponse makeSyncStorageResponse(int code, String lastModified, String offset, String records) {
+ BasicHttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), code, null));
+
+ if (lastModified != null) {
+ response.addHeader(SyncResponse.X_LAST_MODIFIED, lastModified);
+ }
+
+ if (offset != null) {
+ response.addHeader(SyncResponse.X_WEAVE_NEXT_OFFSET, offset);
+ }
+
+ if (records != null) {
+ response.addHeader(SyncResponse.X_WEAVE_RECORDS, records);
+ }
+
+ return new SyncStorageResponse(response);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestRepositorySessionBundle.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestRepositorySessionBundle.java
new file mode 100644
index 000000000..e81d13640
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestRepositorySessionBundle.java
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+@RunWith(TestRunner.class)
+public class TestRepositorySessionBundle {
+ @Test
+ public void testSetGetTimestamp() {
+ RepositorySessionBundle bundle = new RepositorySessionBundle(-1);
+ assertEquals(-1, bundle.getTimestamp());
+
+ bundle.setTimestamp(10);
+ assertEquals(10, bundle.getTimestamp());
+ }
+
+ @Test
+ public void testBumpTimestamp() {
+ RepositorySessionBundle bundle = new RepositorySessionBundle(50);
+ assertEquals(50, bundle.getTimestamp());
+
+ bundle.bumpTimestamp(20);
+ assertEquals(50, bundle.getTimestamp());
+
+ bundle.bumpTimestamp(80);
+ assertEquals(80, bundle.getTimestamp());
+ }
+
+ @Test
+ public void testSerialize() throws Exception {
+ RepositorySessionBundle bundle = new RepositorySessionBundle(50);
+
+ String json = bundle.toJSONString();
+ assertNotNull(json);
+
+ RepositorySessionBundle read = new RepositorySessionBundle(json);
+ assertEquals(50, read.getTimestamp());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestSafeConstrainedServer11Repository.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestSafeConstrainedServer11Repository.java
new file mode 100644
index 000000000..249a7831a
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestSafeConstrainedServer11Repository.java
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.test;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.InfoConfiguration;
+import org.mozilla.gecko.sync.JSONRecordFetcher;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+import org.mozilla.gecko.sync.stage.SafeConstrainedServer11Repository;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import java.net.URISyntaxException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@RunWith(TestRunner.class)
+public class TestSafeConstrainedServer11Repository {
+ private static final int TEST_PORT = HTTPServerTestHelper.getTestPort();
+ private static final String TEST_SERVER = "http://localhost:" + TEST_PORT + "/";
+ private static final String TEST_USERNAME = "c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd";
+ private static final String TEST_BASE_PATH = "/1.1/" + TEST_USERNAME + "/";
+
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return null;
+ }
+
+ protected final InfoCollections infoCollections = new InfoCollections();
+ protected final InfoConfiguration infoConfiguration = new InfoConfiguration();
+
+ private class CountsMockServer extends MockServer {
+ public final AtomicInteger count = new AtomicInteger(0);
+ public final AtomicBoolean error = new AtomicBoolean(false);
+
+ @Override
+ public void handle(Request request, Response response) {
+ final String path = request.getPath().getPath();
+ if (error.get()) {
+ super.handle(request, response, 503, "Unavailable.");
+ return;
+ }
+
+ if (!path.startsWith(TEST_BASE_PATH)) {
+ super.handle(request, response, 404, "Not Found");
+ return;
+ }
+
+ if (path.endsWith("info/collections")) {
+ super.handle(request, response, 200, "{\"rotary\": 123456789}");
+ return;
+ }
+
+ if (path.endsWith("info/collection_counts")) {
+ super.handle(request, response, 200, "{\"rotary\": " + count.get() + "}");
+ return;
+ }
+
+ super.handle(request, response);
+ }
+ }
+
+ /**
+ * Ensure that a {@link SafeConstrainedServer11Repository} will advise
+ * skipping if the reported collection counts are higher than its limit.
+ */
+ @Test
+ public void testShouldSkip() throws URISyntaxException {
+ final HTTPServerTestHelper data = new HTTPServerTestHelper();
+ final CountsMockServer server = new CountsMockServer();
+ data.startHTTPServer(server);
+
+ try {
+ String countsURL = TEST_SERVER + TEST_BASE_PATH + "info/collection_counts";
+ JSONRecordFetcher countFetcher = new JSONRecordFetcher(countsURL, getAuthHeaderProvider());
+ String sort = "sortindex";
+ String collection = "rotary";
+
+ final int TEST_LIMIT = 1000;
+ final SafeConstrainedServer11Repository repo = new SafeConstrainedServer11Repository(
+ collection, getCollectionURL(collection), null, infoCollections, infoConfiguration,
+ TEST_LIMIT, TEST_LIMIT, sort, countFetcher);
+
+ final AtomicBoolean shouldSkipLots = new AtomicBoolean(false);
+ final AtomicBoolean shouldSkipFew = new AtomicBoolean(true);
+ final AtomicBoolean shouldSkip503 = new AtomicBoolean (false);
+
+ WaitHelper.getTestWaiter().performWait(2000, new Runnable() {
+ @Override
+ public void run() {
+ repo.createSession(new RepositorySessionCreationDelegate() {
+ @Override
+ public void onSessionCreated(RepositorySession session) {
+ // Try with too many items.
+ server.count.set(TEST_LIMIT + 1);
+ shouldSkipLots.set(session.shouldSkip());
+
+ // … and few enough that we should sync.
+ server.count.set(TEST_LIMIT - 1);
+ shouldSkipFew.set(session.shouldSkip());
+
+ // Now try with an error response. We advise skipping if we can't
+ // fetch counts, because we'll try again later.
+ server.error.set(true);
+ shouldSkip503.set(session.shouldSkip());
+
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public void onSessionCreateFailed(Exception ex) {
+ WaitHelper.getTestWaiter().performNotify(ex);
+ }
+
+ @Override
+ public RepositorySessionCreationDelegate deferredCreationDelegate() {
+ return this;
+ }
+ }, null);
+ }
+ });
+
+ Assert.assertTrue(shouldSkipLots.get());
+ Assert.assertFalse(shouldSkipFew.get());
+ Assert.assertTrue(shouldSkip503.get());
+
+ } finally {
+ data.stopHTTPServer();
+ }
+ }
+
+ protected String getCollectionURL(String collection) {
+ return TEST_BASE_PATH + "/storage/" + collection;
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/BatchMetaTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/BatchMetaTest.java
new file mode 100644
index 000000000..2e136c117
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/BatchMetaTest.java
@@ -0,0 +1,282 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class BatchMetaTest {
+ private BatchMeta batchMeta;
+ private long byteLimit = 1024;
+ private long recordLimit = 5;
+ private Object lock = new Object();
+ private Long collectionLastModified = 123L;
+
+ @Before
+ public void setUp() throws Exception {
+ batchMeta = new BatchMeta(lock, byteLimit, recordLimit, collectionLastModified);
+ }
+
+ @Test
+ public void testConstructor() {
+ assertEquals(batchMeta.collectionLastModified, collectionLastModified);
+
+ BatchMeta otherBatchMeta = new BatchMeta(lock, byteLimit, recordLimit, null);
+ assertNull(otherBatchMeta.collectionLastModified);
+ }
+
+ @Test
+ public void testGetLastModified() {
+ // Defaults to collection L-M
+ assertEquals(batchMeta.getLastModified(), Long.valueOf(123L));
+
+ try {
+ batchMeta.setLastModified(333L, true);
+ } catch (BatchingUploader.LastModifiedChangedUnexpectedly e) {
+ } catch (BatchingUploader.LastModifiedDidNotChange e) {}
+
+ assertEquals(batchMeta.getLastModified(), Long.valueOf(333L));
+ }
+
+ @Test
+ public void testSetLastModified() {
+ assertEquals(batchMeta.getLastModified(), collectionLastModified);
+
+ try {
+ batchMeta.setLastModified(123L, true);
+ assertEquals(batchMeta.getLastModified(), Long.valueOf(123L));
+ } catch (BatchingUploader.LastModifiedChangedUnexpectedly e) {
+ fail("Should not check for modifications on first L-M set");
+ } catch (BatchingUploader.LastModifiedDidNotChange e) {
+ fail("Should not check for modifications on first L-M set");
+ }
+
+ // Now the same, but passing in 'false' for "expecting to change".
+ batchMeta.reset();
+ assertEquals(batchMeta.getLastModified(), collectionLastModified);
+
+ try {
+ batchMeta.setLastModified(123L, false);
+ assertEquals(batchMeta.getLastModified(), Long.valueOf(123L));
+ } catch (BatchingUploader.LastModifiedChangedUnexpectedly e) {
+ fail("Should not check for modifications on first L-M set");
+ } catch (BatchingUploader.LastModifiedDidNotChange e) {
+ fail("Should not check for modifications on first L-M set");
+ }
+
+ // Test that we can't modify L-M when we're not expecting to
+ try {
+ batchMeta.setLastModified(333L, false);
+ } catch (BatchingUploader.LastModifiedChangedUnexpectedly e) {
+ assertTrue("Must throw when L-M changes unexpectedly", true);
+ } catch (BatchingUploader.LastModifiedDidNotChange e) {
+ fail("Not expecting did-not-change throw");
+ }
+ assertEquals(batchMeta.getLastModified(), Long.valueOf(123L));
+
+ // Test that we can modify L-M when we're expecting to
+ try {
+ batchMeta.setLastModified(333L, true);
+ } catch (BatchingUploader.LastModifiedChangedUnexpectedly e) {
+ fail("Not expecting changed-unexpectedly throw");
+ } catch (BatchingUploader.LastModifiedDidNotChange e) {
+ fail("Not expecting did-not-change throw");
+ }
+ assertEquals(batchMeta.getLastModified(), Long.valueOf(333L));
+
+ // Test that we catch L-M modifications that expect to change but actually don't
+ try {
+ batchMeta.setLastModified(333L, true);
+ } catch (BatchingUploader.LastModifiedChangedUnexpectedly e) {
+ fail("Not expecting changed-unexpectedly throw");
+ } catch (BatchingUploader.LastModifiedDidNotChange e) {
+ assertTrue("Expected-to-change-but-did-not-change didn't throw", true);
+ }
+ assertEquals(batchMeta.getLastModified(), Long.valueOf(333));
+ }
+
+ @Test
+ public void testSetToken() {
+ assertNull(batchMeta.getToken());
+
+ try {
+ batchMeta.setToken("MTIzNA", false);
+ } catch (BatchingUploader.TokenModifiedException e) {
+ fail("Should be able to set token for the first time");
+ }
+ assertEquals("MTIzNA", batchMeta.getToken());
+
+ try {
+ batchMeta.setToken("XYCvNA", false);
+ } catch (BatchingUploader.TokenModifiedException e) {
+ assertTrue("Should not be able to modify a token", true);
+ }
+ assertEquals("MTIzNA", batchMeta.getToken());
+
+ try {
+ batchMeta.setToken("XYCvNA", true);
+ } catch (BatchingUploader.TokenModifiedException e) {
+ assertTrue("Should catch non-null tokens during onCommit sets", true);
+ }
+ assertEquals("MTIzNA", batchMeta.getToken());
+
+ try {
+ batchMeta.setToken(null, true);
+ } catch (BatchingUploader.TokenModifiedException e) {
+ fail("Should be able to set token to null during onCommit set");
+ }
+ assertNull(batchMeta.getToken());
+ }
+
+ @Test
+ public void testRecordSucceeded() {
+ assertTrue(batchMeta.getSuccessRecordGuids().isEmpty());
+
+ batchMeta.recordSucceeded("guid1");
+
+ assertTrue(batchMeta.getSuccessRecordGuids().size() == 1);
+ assertTrue(batchMeta.getSuccessRecordGuids().contains("guid1"));
+
+ try {
+ batchMeta.recordSucceeded(null);
+ fail();
+ } catch (IllegalStateException e) {
+ assertTrue("Should not be able to 'succeed' a null guid", true);
+ }
+ }
+
+ @Test
+ public void testByteLimits() {
+ assertTrue(batchMeta.canFit(0));
+
+ // Should just fit
+ assertTrue(batchMeta.canFit(byteLimit - BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT));
+
+ // Can't fit a record due to payload overhead.
+ assertFalse(batchMeta.canFit(byteLimit));
+
+ assertFalse(batchMeta.canFit(byteLimit + BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT));
+ assertFalse(batchMeta.canFit(byteLimit * 1000));
+
+ long recordDelta = byteLimit / 2;
+ assertFalse(batchMeta.addAndEstimateIfFull(recordDelta));
+
+ // Record delta shouldn't fit due to payload overhead.
+ assertFalse(batchMeta.canFit(recordDelta));
+ }
+
+ @Test
+ public void testCountLimits() {
+ // Our record limit is 5, let's add 4.
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+
+ // 5th record still fits in
+ assertTrue(batchMeta.canFit(1));
+
+ // Add the 5th record
+ assertTrue(batchMeta.addAndEstimateIfFull(1));
+
+ // 6th record won't fit
+ assertFalse(batchMeta.canFit(1));
+ }
+
+ @Test
+ public void testNeedCommit() {
+ assertFalse(batchMeta.needToCommit());
+
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+
+ assertTrue(batchMeta.needToCommit());
+
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+
+ assertTrue(batchMeta.needToCommit());
+
+ batchMeta.reset();
+
+ assertFalse(batchMeta.needToCommit());
+ }
+
+ @Test
+ public void testAdd() {
+ // 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 both.
+ assertTrue(batchMeta.getByteCount() == 2 * BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT);
+ assertTrue(batchMeta.getRecordCount() == 0);
+
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+
+ assertTrue(batchMeta.getByteCount() == (1 + BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT));
+ assertTrue(batchMeta.getRecordCount() == 1);
+
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+
+ assertTrue(batchMeta.getByteCount() == (4 + BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT));
+ assertTrue(batchMeta.getRecordCount() == 4);
+
+ assertTrue(batchMeta.addAndEstimateIfFull(1));
+
+ try {
+ assertTrue(batchMeta.addAndEstimateIfFull(1));
+ fail("BatchMeta should not let us insert records that won't fit");
+ } catch (IllegalStateException e) {
+ assertTrue(true);
+ }
+ }
+
+ @Test
+ public void testReset() {
+ assertTrue(batchMeta.getByteCount() == 2 * BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT);
+ assertTrue(batchMeta.getRecordCount() == 0);
+ assertTrue(batchMeta.getSuccessRecordGuids().isEmpty());
+
+ // Shouldn't throw even if already empty
+ batchMeta.reset();
+ assertTrue(batchMeta.getByteCount() == 2 * BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT);
+ assertTrue(batchMeta.getRecordCount() == 0);
+ assertTrue(batchMeta.getSuccessRecordGuids().isEmpty());
+
+ assertFalse(batchMeta.addAndEstimateIfFull(1));
+ batchMeta.recordSucceeded("guid1");
+ try {
+ batchMeta.setToken("MTIzNA", false);
+ } catch (BatchingUploader.TokenModifiedException e) {}
+ try {
+ batchMeta.setLastModified(333L, true);
+ } catch (BatchingUploader.LastModifiedChangedUnexpectedly e) {
+ } catch (BatchingUploader.LastModifiedDidNotChange e) {}
+ assertEquals(Long.valueOf(333L), batchMeta.getLastModified());
+ assertEquals("MTIzNA", batchMeta.getToken());
+ assertTrue(batchMeta.getSuccessRecordGuids().size() == 1);
+
+ batchMeta.reset();
+
+ // Counts must be reset
+ assertTrue(batchMeta.getByteCount() == 2 * BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT);
+ assertTrue(batchMeta.getRecordCount() == 0);
+ assertTrue(batchMeta.getSuccessRecordGuids().isEmpty());
+
+ // Collection L-M shouldn't change
+ assertEquals(batchMeta.collectionLastModified, collectionLastModified);
+
+ // Token must be reset
+ assertNull(batchMeta.getToken());
+
+ // L-M must be reverted to collection L-M
+ assertEquals(batchMeta.getLastModified(), collectionLastModified);
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploaderTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploaderTest.java
new file mode 100644
index 000000000..5ce94b222
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploaderTest.java
@@ -0,0 +1,441 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+import android.support.annotation.NonNull;
+
+import static org.junit.Assert.*;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.MockRecord;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.InfoConfiguration;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.Server11Repository;
+import org.mozilla.gecko.sync.repositories.Server11RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
+
+import java.net.URISyntaxException;
+import java.util.Random;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+
+@RunWith(TestRunner.class)
+public class BatchingUploaderTest {
+ class MockExecutorService implements Executor {
+ public int totalPayloads = 0;
+ public int commitPayloads = 0;
+
+ @Override
+ public void execute(@NonNull Runnable command) {
+ ++totalPayloads;
+ if (((RecordUploadRunnable) command).isCommit) {
+ ++commitPayloads;
+ }
+ }
+ }
+
+ class MockStoreDelegate implements RepositorySessionStoreDelegate {
+ public int storeFailed = 0;
+ public int storeSucceeded = 0;
+ public int storeCompleted = 0;
+
+ @Override
+ public void onRecordStoreFailed(Exception ex, String recordGuid) {
+ ++storeFailed;
+ }
+
+ @Override
+ public void onRecordStoreSucceeded(String guid) {
+ ++storeSucceeded;
+ }
+
+ @Override
+ public void onStoreCompleted(long storeEnd) {
+ ++storeCompleted;
+ }
+
+ @Override
+ public RepositorySessionStoreDelegate deferredStoreDelegate(ExecutorService executor) {
+ return null;
+ }
+ }
+
+ private Executor workQueue;
+ private RepositorySessionStoreDelegate storeDelegate;
+
+ @Before
+ public void setUp() throws Exception {
+ workQueue = new MockExecutorService();
+ storeDelegate = new MockStoreDelegate();
+ }
+
+ @Test
+ public void testProcessEvenPayloadBatch() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 4);
+
+ MockRecord record = new MockRecord(Utils.generateGuid(), null, 0, false);
+ // 1st
+ uploader.process(record);
+ assertEquals(0, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ // 2nd -> payload full
+ uploader.process(record);
+ assertEquals(1, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ // 3rd
+ uploader.process(record);
+ assertEquals(1, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ // 4th -> batch & payload full
+ uploader.process(record);
+ assertEquals(2, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ // 5th
+ uploader.process(record);
+ assertEquals(2, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ // 6th -> payload full
+ uploader.process(record);
+ assertEquals(3, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ // 7th
+ uploader.process(record);
+ assertEquals(3, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ // 8th -> batch & payload full
+ uploader.process(record);
+ assertEquals(4, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(2, ((MockExecutorService) workQueue).commitPayloads);
+ // 9th
+ uploader.process(record);
+ assertEquals(4, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(2, ((MockExecutorService) workQueue).commitPayloads);
+ // 10th -> payload full
+ uploader.process(record);
+ assertEquals(5, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(2, ((MockExecutorService) workQueue).commitPayloads);
+ // 11th
+ uploader.process(record);
+ assertEquals(5, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(2, ((MockExecutorService) workQueue).commitPayloads);
+ // 12th -> batch & payload full
+ uploader.process(record);
+ assertEquals(6, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(3, ((MockExecutorService) workQueue).commitPayloads);
+ // 13th
+ uploader.process(record);
+ assertEquals(6, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(3, ((MockExecutorService) workQueue).commitPayloads);
+ }
+
+ @Test
+ public void testProcessUnevenPayloadBatch() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 5);
+
+ MockRecord record = new MockRecord(Utils.generateGuid(), null, 0, false);
+ // 1st
+ uploader.process(record);
+ assertEquals(0, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ // 2nd -> payload full
+ uploader.process(record);
+ assertEquals(1, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ // 3rd
+ uploader.process(record);
+ assertEquals(1, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ // 4th -> payload full
+ uploader.process(record);
+ assertEquals(2, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ // 5th -> batch full
+ uploader.process(record);
+ assertEquals(3, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ // 6th -> starts new batch
+ uploader.process(record);
+ assertEquals(3, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ // 7th -> payload full
+ uploader.process(record);
+ assertEquals(4, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ // 8th
+ uploader.process(record);
+ assertEquals(4, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ // 9th -> payload full
+ uploader.process(record);
+ assertEquals(5, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ // 10th -> batch full
+ uploader.process(record);
+ assertEquals(6, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(2, ((MockExecutorService) workQueue).commitPayloads);
+ // 11th -> starts new batch
+ uploader.process(record);
+ assertEquals(6, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(2, ((MockExecutorService) workQueue).commitPayloads);
+ }
+
+ @Test
+ public void testNonBatchingOptimization() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 4);
+
+ MockRecord record = new MockRecord(Utils.generateGuid(), null, 0, false);
+ // 1st
+ uploader.process(record);
+ assertEquals(0, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ // 2nd
+ uploader.process(record);
+ assertEquals(1, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ // 3rd
+ uploader.process(record);
+ assertEquals(1, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ // 4th
+ uploader.process(record);
+ assertEquals(2, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+
+ // 5th
+ uploader.process(record);
+ assertEquals(2, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+
+ // And now we tell uploader that batching isn't supported.
+ // It shouldn't bother with batches from now on, just payloads.
+ uploader.setInBatchingMode(false);
+
+ // 6th
+ uploader.process(record);
+ assertEquals(3, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+
+ // 7th
+ uploader.process(record);
+ assertEquals(3, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+
+ // 8th
+ uploader.process(record);
+ assertEquals(4, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+
+ // 9th
+ uploader.process(record);
+ assertEquals(4, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+
+ // 10th
+ uploader.process(record);
+ assertEquals(5, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ }
+
+ @Test
+ public void testPreemtiveUploadByteCounts() {
+ // While processing a record, if we know for sure that another one won't fit,
+ // we upload the payload.
+ BatchingUploader uploader = makeConstrainedUploader(3, 6);
+
+ // Payload byte max: 1024; batch byte max: 4096
+ MockRecord record = new MockRecord(Utils.generateGuid(), null, 0, false, 400);
+
+ uploader.process(record);
+ assertEquals(0, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+
+ // After 2nd record, byte count is at 800+overhead. Our payload max is 1024, so it's unlikely
+ // we can fit another record at this pace. Expect payload to be uploaded.
+ uploader.process(record);
+ assertEquals(1, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+
+ // After this record, we'll have less than 124 bytes of room left in the payload. Expect upload.
+ record = new MockRecord(Utils.generateGuid(), null, 0, false, 970);
+ uploader.process(record);
+ assertEquals(2, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+
+ uploader.process(record);
+ assertEquals(3, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+
+ // At this point our byte count for the batch is at 3600+overhead;
+ // since we have just 496 bytes left in the batch, it's unlikely we'll fit another record.
+ // Expect a batch commit
+ uploader.process(record);
+ assertEquals(4, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ }
+
+ @Test
+ public void testRandomPayloadSizesBatching() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 4);
+
+ final Random random = new Random();
+ for (int i = 0; i < 15000; i++) {
+ uploader.process(new MockRecord(Utils.generateGuid(), null, 0, false, random.nextInt(15000)));
+ }
+ }
+
+ @Test
+ public void testRandomPayloadSizesNonBatching() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 4);
+
+ final Random random = new Random();
+ uploader.setInBatchingMode(false);
+ for (int i = 0; i < 15000; i++) {
+ uploader.process(new MockRecord(Utils.generateGuid(), null, 0, false, random.nextInt(15000)));
+ }
+ }
+
+ @Test
+ public void testRandomPayloadSizesNonBatchingDelayed() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 4);
+
+ final Random random = new Random();
+ // Delay telling uploader that batching isn't supported.
+ // Randomize how many records we wait for.
+ final int delay = random.nextInt(20);
+ for (int i = 0; i < 15000; i++) {
+ if (delay == i) {
+ uploader.setInBatchingMode(false);
+ }
+ uploader.process(new MockRecord(Utils.generateGuid(), null, 0, false, random.nextInt(15000)));
+ }
+ }
+
+ @Test
+ public void testNoMoreRecordsAfterPayloadPost() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 4);
+
+ // Process two records (payload limit is also two, batch is four),
+ // and ensure that 'no more records' commits.
+ MockRecord record = new MockRecord(Utils.generateGuid(), null, 0, false);
+ uploader.process(record);
+ uploader.process(record);
+ uploader.setInBatchingMode(true);
+ uploader.commitIfNecessaryAfterLastPayload();
+ // One will be a payload post, the other one is batch commit (empty payload)
+ assertEquals(2, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ }
+
+ @Test
+ public void testNoMoreRecordsAfterPayloadPostWithOneRecordLeft() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 4);
+
+ // Process two records (payload limit is also two, batch is four),
+ // and ensure that 'no more records' commits.
+ MockRecord record = new MockRecord(Utils.generateGuid(), null, 0, false);
+ uploader.process(record);
+ uploader.process(record);
+ uploader.process(record);
+ uploader.commitIfNecessaryAfterLastPayload();
+ // One will be a payload post, the other one is batch commit (one record payload)
+ assertEquals(2, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ }
+
+ @Test
+ public void testNoMoreRecordsNoOp() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 4);
+
+ uploader.commitIfNecessaryAfterLastPayload();
+ assertEquals(0, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ }
+
+ @Test
+ public void testNoMoreRecordsNoOpAfterCommit() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 4);
+
+ MockRecord record = new MockRecord(Utils.generateGuid(), null, 0, false);
+ uploader.process(record);
+ uploader.process(record);
+ uploader.process(record);
+ uploader.process(record);
+ assertEquals(2, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+
+ uploader.commitIfNecessaryAfterLastPayload();
+ assertEquals(2, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ }
+
+ @Test
+ public void testNoMoreRecordsEvenNonBatching() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 4);
+
+ // Process two records (payload limit is also two, batch is four),
+ // set non-batching mode, and ensure that 'no more records' doesn't commit.
+ MockRecord record = new MockRecord(Utils.generateGuid(), null, 0, false);
+ uploader.process(record);
+ uploader.process(record);
+ uploader.setInBatchingMode(false);
+ uploader.commitIfNecessaryAfterLastPayload();
+ // One will be a payload post, the other one is batch commit (one record payload)
+ assertEquals(1, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
+ }
+
+ @Test
+ public void testNoMoreRecordsIncompletePayload() {
+ BatchingUploader uploader = makeConstrainedUploader(2, 4);
+
+ // We have one record (payload limit is 2), and "no-more-records" signal should commit it.
+ MockRecord record = new MockRecord(Utils.generateGuid(), null, 0, false);
+ uploader.process(record);
+
+ uploader.commitIfNecessaryAfterLastPayload();
+ assertEquals(1, ((MockExecutorService) workQueue).totalPayloads);
+ assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
+ }
+
+ private BatchingUploader makeConstrainedUploader(long maxPostRecords, long maxTotalRecords) {
+ Server11RepositorySession server11RepositorySession = new Server11RepositorySession(
+ makeCountConstrainedRepository(maxPostRecords, maxTotalRecords)
+ );
+ server11RepositorySession.setStoreDelegate(storeDelegate);
+ return new BatchingUploader(server11RepositorySession, workQueue, storeDelegate);
+ }
+
+ private Server11Repository makeCountConstrainedRepository(long maxPostRecords, long maxTotalRecords) {
+ return makeConstrainedRepository(1024, 1024, maxPostRecords, 4096, maxTotalRecords);
+ }
+
+ private Server11Repository makeConstrainedRepository(long maxRequestBytes, long maxPostBytes, long maxPostRecords, long maxTotalBytes, long maxTotalRecords) {
+ ExtendedJSONObject infoConfigurationJSON = new ExtendedJSONObject();
+ infoConfigurationJSON.put(InfoConfiguration.MAX_TOTAL_BYTES, maxTotalBytes);
+ infoConfigurationJSON.put(InfoConfiguration.MAX_TOTAL_RECORDS, maxTotalRecords);
+ infoConfigurationJSON.put(InfoConfiguration.MAX_POST_RECORDS, maxPostRecords);
+ infoConfigurationJSON.put(InfoConfiguration.MAX_POST_BYTES, maxPostBytes);
+ infoConfigurationJSON.put(InfoConfiguration.MAX_REQUEST_BYTES, maxRequestBytes);
+
+ InfoConfiguration infoConfiguration = new InfoConfiguration(infoConfigurationJSON);
+
+ try {
+ return new Server11Repository(
+ "dummyCollection",
+ "http://dummy.url/",
+ null,
+ new InfoCollections(),
+ infoConfiguration
+ );
+ } catch (URISyntaxException e) {
+ // Won't throw, and this won't happen.
+ return null;
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/PayloadTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/PayloadTest.java
new file mode 100644
index 000000000..b1d6dd9d0
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/PayloadTest.java
@@ -0,0 +1,137 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class PayloadTest {
+ private Payload payload;
+ private long byteLimit = 1024;
+ private long recordLimit = 5;
+ private Object lock = new Object();
+
+ @Before
+ public void setUp() throws Exception {
+ payload = new Payload(lock, byteLimit, recordLimit);
+ }
+
+ @Test
+ public void testByteLimits() {
+ assertTrue(payload.canFit(0));
+
+ // Should just fit
+ assertTrue(payload.canFit(byteLimit - BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT));
+
+ // Can't fit a record due to payload overhead.
+ assertFalse(payload.canFit(byteLimit));
+
+ assertFalse(payload.canFit(byteLimit + BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT));
+ assertFalse(payload.canFit(byteLimit * 1000));
+
+ long recordDelta = byteLimit / 2;
+ assertFalse(payload.addAndEstimateIfFull(recordDelta, new byte[0], null));
+
+ // Record delta shouldn't fit due to payload overhead.
+ assertFalse(payload.canFit(recordDelta));
+ }
+
+ @Test
+ public void testCountLimits() {
+ byte[] bytes = new byte[0];
+
+ // Our record limit is 5, let's add 4.
+ assertFalse(payload.addAndEstimateIfFull(1, bytes, null));
+ assertFalse(payload.addAndEstimateIfFull(1, bytes, null));
+ assertFalse(payload.addAndEstimateIfFull(1, bytes, null));
+ assertFalse(payload.addAndEstimateIfFull(1, bytes, null));
+
+ // 5th record still fits in
+ assertTrue(payload.canFit(1));
+
+ // Add the 5th record
+ assertTrue(payload.addAndEstimateIfFull(1, bytes, null));
+
+ // 6th record won't fit
+ assertFalse(payload.canFit(1));
+ }
+
+ @Test
+ public void testAdd() {
+ assertTrue(payload.getByteCount() == 2 * BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT);
+ assertTrue(payload.getRecordCount() == 0);
+ assertTrue(payload.isEmpty());
+ assertTrue(payload.getRecordsBuffer().isEmpty());
+ assertTrue(payload.getRecordGuidsBuffer().isEmpty());
+
+ try {
+ payload.addAndEstimateIfFull(1024);
+ fail("Simple add is not supported");
+ } catch (UnsupportedOperationException e) {
+ assertTrue(true);
+ }
+
+ byte[] recordBytes1 = new byte[100];
+ assertFalse(payload.addAndEstimateIfFull(1, recordBytes1, "guid1"));
+
+ assertTrue(payload.getRecordsBuffer().size() == 1);
+ assertTrue(payload.getRecordGuidsBuffer().size() == 1);
+ assertTrue(payload.getRecordGuidsBuffer().contains("guid1"));
+ assertTrue(payload.getRecordsBuffer().contains(recordBytes1));
+
+ assertTrue(payload.getByteCount() == (1 + BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT));
+ assertTrue(payload.getRecordCount() == 1);
+
+ assertFalse(payload.isEmpty());
+
+ assertFalse(payload.addAndEstimateIfFull(1, recordBytes1, "guid2"));
+ assertFalse(payload.addAndEstimateIfFull(1, recordBytes1, "guid3"));
+ assertFalse(payload.addAndEstimateIfFull(1, recordBytes1, "guid4"));
+
+ assertTrue(payload.getByteCount() == (4 + BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT));
+ assertTrue(payload.getRecordCount() == 4);
+
+ assertTrue(payload.addAndEstimateIfFull(1, recordBytes1, "guid5"));
+
+ try {
+ assertTrue(payload.addAndEstimateIfFull(1, recordBytes1, "guid6"));
+ fail("Payload should not let us insert records that won't fit");
+ } catch (IllegalStateException e) {
+ assertTrue(true);
+ }
+ }
+
+ @Test
+ public void testReset() {
+ assertTrue(payload.getByteCount() == 2 * BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT);
+ assertTrue(payload.getRecordCount() == 0);
+ assertTrue(payload.getRecordsBuffer().isEmpty());
+ assertTrue(payload.getRecordGuidsBuffer().isEmpty());
+ assertTrue(payload.isEmpty());
+
+ // Shouldn't throw even if already empty
+ payload.reset();
+ assertTrue(payload.getByteCount() == 2 * BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT);
+ assertTrue(payload.getRecordCount() == 0);
+ assertTrue(payload.getRecordsBuffer().isEmpty());
+ assertTrue(payload.getRecordGuidsBuffer().isEmpty());
+ assertTrue(payload.isEmpty());
+
+ byte[] recordBytes1 = new byte[100];
+ assertFalse(payload.addAndEstimateIfFull(1, recordBytes1, "guid1"));
+ assertFalse(payload.isEmpty());
+ payload.reset();
+
+ assertTrue(payload.getByteCount() == 2 * BatchingUploader.PER_PAYLOAD_OVERHEAD_BYTE_COUNT);
+ assertTrue(payload.getRecordCount() == 0);
+ assertTrue(payload.getRecordsBuffer().isEmpty());
+ assertTrue(payload.getRecordGuidsBuffer().isEmpty());
+ assertTrue(payload.isEmpty());
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/PayloadUploadDelegateTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/PayloadUploadDelegateTest.java
new file mode 100644
index 000000000..fc43c2f5e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/PayloadUploadDelegateTest.java
@@ -0,0 +1,404 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.InfoConfiguration;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.net.SyncResponse;
+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.RepositorySessionStoreDelegate;
+
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.concurrent.Executor;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.entity.BasicHttpEntity;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+import ch.boye.httpclientandroidlib.message.BasicStatusLine;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class PayloadUploadDelegateTest {
+ private BatchingUploader batchingUploader;
+
+ class MockUploader extends BatchingUploader {
+ public final ArrayList<String> successRecords = new ArrayList<>();
+ public final HashMap<String, Exception> failedRecords = new HashMap<>();
+ public boolean didLastPayloadFail = false;
+
+ public ArrayList<SyncStorageResponse> successResponses = new ArrayList<>();
+ public int commitPayloadsSucceeded = 0;
+ public int lastPayloadsSucceeded = 0;
+
+ public MockUploader(final Server11RepositorySession repositorySession, final Executor workQueue, final RepositorySessionStoreDelegate sessionStoreDelegate) {
+ super(repositorySession, workQueue, sessionStoreDelegate);
+ }
+
+ @Override
+ public void payloadSucceeded(final SyncStorageResponse response, final boolean isCommit, final boolean isLastPayload) {
+ successResponses.add(response);
+ if (isCommit) {
+ ++commitPayloadsSucceeded;
+ }
+ if (isLastPayload) {
+ ++lastPayloadsSucceeded;
+ }
+ }
+
+ @Override
+ public void recordSucceeded(final String recordGuid) {
+ successRecords.add(recordGuid);
+ }
+
+ @Override
+ public void recordFailed(final String recordGuid) {
+ recordFailed(new Exception(), recordGuid);
+ }
+
+ @Override
+ public void recordFailed(final Exception e, final String recordGuid) {
+ failedRecords.put(recordGuid, e);
+ }
+
+ @Override
+ public void lastPayloadFailed() {
+ didLastPayloadFail = true;
+ }
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ Server11Repository server11Repository = new Server11Repository(
+ "dummyCollection",
+ "http://dummy.url/",
+ null,
+ new InfoCollections(),
+ new InfoConfiguration()
+ );
+ batchingUploader = new MockUploader(
+ new Server11RepositorySession(server11Repository),
+ null,
+ null
+ );
+ }
+
+ @Test
+ public void testHandleRequestSuccessNonSuccess() {
+ ArrayList<String> postedGuids = new ArrayList<>(2);
+ postedGuids.add("testGuid1");
+ postedGuids.add("testGuid2");
+ PayloadUploadDelegate payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, false);
+
+ // Test that non-2* responses aren't processed
+ payloadUploadDelegate.handleRequestSuccess(makeSyncStorageResponse(404, null, null));
+ assertEquals(2, ((MockUploader) batchingUploader).failedRecords.size());
+ assertFalse(((MockUploader) batchingUploader).didLastPayloadFail);
+ assertEquals(IllegalStateException.class,
+ ((MockUploader) batchingUploader).failedRecords.get("testGuid1").getClass());
+ assertEquals(IllegalStateException.class,
+ ((MockUploader) batchingUploader).failedRecords.get("testGuid2").getClass());
+ }
+
+ @Test
+ public void testHandleRequestSuccessNoHeaders() {
+ ArrayList<String> postedGuids = new ArrayList<>(2);
+ postedGuids.add("testGuid1");
+ postedGuids.add("testGuid2");
+ PayloadUploadDelegate payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, false);
+
+ // Test that responses without X-Last-Modified header aren't processed
+ payloadUploadDelegate.handleRequestSuccess(makeSyncStorageResponse(200, null, null));
+ assertEquals(2, ((MockUploader) batchingUploader).failedRecords.size());
+ assertFalse(((MockUploader) batchingUploader).didLastPayloadFail);
+ assertEquals(IllegalStateException.class,
+ ((MockUploader) batchingUploader).failedRecords.get("testGuid1").getClass());
+ assertEquals(IllegalStateException.class,
+ ((MockUploader) batchingUploader).failedRecords.get("testGuid2").getClass());
+ }
+
+ @Test
+ public void testHandleRequestSuccessBadBody() {
+ ArrayList<String> postedGuids = new ArrayList<>(2);
+ postedGuids.add("testGuid1");
+ postedGuids.add("testGuid2");
+ PayloadUploadDelegate payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, true);
+
+ // Test that we catch json processing errors
+ payloadUploadDelegate.handleRequestSuccess(makeSyncStorageResponse(200, "non json body", "123"));
+ assertEquals(2, ((MockUploader) batchingUploader).failedRecords.size());
+ assertTrue(((MockUploader) batchingUploader).didLastPayloadFail);
+ assertEquals(NonObjectJSONException.class,
+ ((MockUploader) batchingUploader).failedRecords.get("testGuid1").getClass());
+ assertEquals(NonObjectJSONException.class,
+ ((MockUploader) batchingUploader).failedRecords.get("testGuid2").getClass());
+ }
+
+ @Test
+ public void testHandleRequestSuccess202NoToken() {
+ ArrayList<String> postedGuids = new ArrayList<>(1);
+ postedGuids.add("testGuid1");
+ PayloadUploadDelegate payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, true);
+
+ // Test that we catch absent tokens in 202 responses
+ payloadUploadDelegate.handleRequestSuccess(makeSyncStorageResponse(202, "{\"success\": []}", "123"));
+ assertEquals(1, ((MockUploader) batchingUploader).failedRecords.size());
+ assertEquals(IllegalStateException.class,
+ ((MockUploader) batchingUploader).failedRecords.get("testGuid1").getClass());
+ }
+
+ @Test
+ public void testHandleRequestSuccessBad200() {
+ ArrayList<String> postedGuids = new ArrayList<>(1);
+ postedGuids.add("testGuid1");
+
+ PayloadUploadDelegate payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, false);
+
+ // Test that if in batching mode and saw the token, 200 must be a response to a commit
+ try {
+ batchingUploader.getCurrentBatch().setToken("MTIzNA", true);
+ } catch (BatchingUploader.BatchingUploaderException e) {}
+ batchingUploader.setInBatchingMode(true);
+
+ // not a commit, so should fail
+ payloadUploadDelegate.handleRequestSuccess(makeSyncStorageResponse(200, "{\"success\": []}", "123"));
+ assertEquals(1, ((MockUploader) batchingUploader).failedRecords.size());
+ assertEquals(IllegalStateException.class,
+ ((MockUploader) batchingUploader).failedRecords.get("testGuid1").getClass());
+ }
+
+ @Test
+ public void testHandleRequestSuccessNonBatchingFailedLM() {
+ ArrayList<String> postedGuids = new ArrayList<>(1);
+ postedGuids.add("guid1");
+ postedGuids.add("guid2");
+ postedGuids.add("guid3");
+ PayloadUploadDelegate payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, false);
+
+ payloadUploadDelegate.handleRequestSuccess(
+ makeSyncStorageResponse(200, "{\"success\": [\"guid1\", \"guid2\", \"guid3\"]}", "123"));
+ assertEquals(0, ((MockUploader) batchingUploader).failedRecords.size());
+ assertEquals(3, ((MockUploader) batchingUploader).successRecords.size());
+ assertFalse(((MockUploader) batchingUploader).didLastPayloadFail);
+ assertEquals(1, ((MockUploader) batchingUploader).successResponses.size());
+ assertEquals(0, ((MockUploader) batchingUploader).commitPayloadsSucceeded);
+ assertEquals(0, ((MockUploader) batchingUploader).lastPayloadsSucceeded);
+
+ // These should fail, because we're returning a non-changed L-M in a non-batching mode
+ postedGuids.add("guid4");
+ postedGuids.add("guid6");
+ payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, false);
+ payloadUploadDelegate.handleRequestSuccess(
+ makeSyncStorageResponse(200, "{\"success\": [\"guid4\", 5, \"guid6\"]}", "123"));
+ assertEquals(5, ((MockUploader) batchingUploader).failedRecords.size());
+ assertEquals(3, ((MockUploader) batchingUploader).successRecords.size());
+ assertFalse(((MockUploader) batchingUploader).didLastPayloadFail);
+ assertEquals(1, ((MockUploader) batchingUploader).successResponses.size());
+ assertEquals(0, ((MockUploader) batchingUploader).commitPayloadsSucceeded);
+ assertEquals(0, ((MockUploader) batchingUploader).lastPayloadsSucceeded);
+ assertEquals(BatchingUploader.LastModifiedDidNotChange.class,
+ ((MockUploader) batchingUploader).failedRecords.get("guid4").getClass());
+ }
+
+ @Test
+ public void testHandleRequestSuccessNonBatching() {
+ ArrayList<String> postedGuids = new ArrayList<>();
+ postedGuids.add("guid1");
+ postedGuids.add("guid2");
+ postedGuids.add("guid3");
+ PayloadUploadDelegate payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, false);
+ payloadUploadDelegate.handleRequestSuccess(
+ makeSyncStorageResponse(200, "{\"success\": [\"guid1\", \"guid2\", \"guid3\"], \"failed\": {}}", "123"));
+
+ postedGuids = new ArrayList<>();
+ postedGuids.add("guid4");
+ postedGuids.add("guid5");
+ payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, false);
+ payloadUploadDelegate.handleRequestSuccess(
+ makeSyncStorageResponse(200, "{\"success\": [\"guid4\", \"guid5\"], \"failed\": {}}", "333"));
+
+ postedGuids = new ArrayList<>();
+ postedGuids.add("guid6");
+ payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, true);
+ payloadUploadDelegate.handleRequestSuccess(
+ makeSyncStorageResponse(200, "{\"success\": [\"guid6\"], \"failed\": {}}", "444"));
+
+ assertEquals(0, ((MockUploader) batchingUploader).failedRecords.size());
+ assertEquals(6, ((MockUploader) batchingUploader).successRecords.size());
+ assertFalse(((MockUploader) batchingUploader).didLastPayloadFail);
+ assertEquals(3, ((MockUploader) batchingUploader).successResponses.size());
+ assertEquals(0, ((MockUploader) batchingUploader).commitPayloadsSucceeded);
+ assertEquals(1, ((MockUploader) batchingUploader).lastPayloadsSucceeded);
+ assertFalse(batchingUploader.getInBatchingMode());
+
+ postedGuids = new ArrayList<>();
+ postedGuids.add("guid7");
+ postedGuids.add("guid8");
+ payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, true);
+ payloadUploadDelegate.handleRequestSuccess(
+ makeSyncStorageResponse(200, "{\"success\": [\"guid8\"], \"failed\": {\"guid7\": \"reason\"}}", "555"));
+ assertEquals(1, ((MockUploader) batchingUploader).failedRecords.size());
+ assertTrue(((MockUploader) batchingUploader).failedRecords.containsKey("guid7"));
+ assertEquals(7, ((MockUploader) batchingUploader).successRecords.size());
+ assertFalse(((MockUploader) batchingUploader).didLastPayloadFail);
+ assertEquals(4, ((MockUploader) batchingUploader).successResponses.size());
+ assertEquals(0, ((MockUploader) batchingUploader).commitPayloadsSucceeded);
+ assertEquals(2, ((MockUploader) batchingUploader).lastPayloadsSucceeded);
+ assertFalse(batchingUploader.getInBatchingMode());
+ }
+
+ @Test
+ public void testHandleRequestSuccessBatching() {
+ ArrayList<String> postedGuids = new ArrayList<>();
+ postedGuids.add("guid1");
+ postedGuids.add("guid2");
+ postedGuids.add("guid3");
+ PayloadUploadDelegate payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, false);
+ payloadUploadDelegate.handleRequestSuccess(
+ makeSyncStorageResponse(202, "{\"batch\": \"MTIzNA\", \"success\": [\"guid1\", \"guid2\", \"guid3\"], \"failed\": {}}", "123"));
+
+ assertTrue(batchingUploader.getInBatchingMode());
+ assertEquals("MTIzNA", batchingUploader.getCurrentBatch().getToken());
+
+ postedGuids = new ArrayList<>();
+ postedGuids.add("guid4");
+ postedGuids.add("guid5");
+ postedGuids.add("guid6");
+ payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, false, false);
+ payloadUploadDelegate.handleRequestSuccess(
+ makeSyncStorageResponse(202, "{\"batch\": \"MTIzNA\", \"success\": [\"guid4\", \"guid5\", \"guid6\"], \"failed\": {}}", "123"));
+
+ assertTrue(batchingUploader.getInBatchingMode());
+ assertEquals("MTIzNA", batchingUploader.getCurrentBatch().getToken());
+
+ postedGuids = new ArrayList<>();
+ postedGuids.add("guid7");
+ payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, true, false);
+ payloadUploadDelegate.handleRequestSuccess(
+ makeSyncStorageResponse(200, "{\"success\": [\"guid6\"], \"failed\": {}}", "222"));
+
+ // Even though everything indicates we're not in a batching, we were, so test that
+ // we don't reset the flag.
+ assertTrue(batchingUploader.getInBatchingMode());
+ assertNull(batchingUploader.getCurrentBatch().getToken());
+
+ postedGuids = new ArrayList<>();
+ postedGuids.add("guid8");
+ payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, postedGuids, true, true);
+ payloadUploadDelegate.handleRequestSuccess(
+ makeSyncStorageResponse(200, "{\"success\": [\"guid7\"], \"failed\": {}}", "333"));
+
+ assertEquals(0, ((MockUploader) batchingUploader).failedRecords.size());
+ assertEquals(8, ((MockUploader) batchingUploader).successRecords.size());
+ assertFalse(((MockUploader) batchingUploader).didLastPayloadFail);
+ assertEquals(4, ((MockUploader) batchingUploader).successResponses.size());
+ assertEquals(2, ((MockUploader) batchingUploader).commitPayloadsSucceeded);
+ assertEquals(1, ((MockUploader) batchingUploader).lastPayloadsSucceeded);
+ assertTrue(batchingUploader.getInBatchingMode());
+ }
+
+ @Test
+ public void testHandleRequestError() {
+ ArrayList<String> postedGuids = new ArrayList<>(3);
+ postedGuids.add("testGuid1");
+ postedGuids.add("testGuid2");
+ postedGuids.add("testGuid3");
+ PayloadUploadDelegate payloadUploadDelegate = new PayloadUploadDelegate(batchingUploader, postedGuids, false, false);
+
+ IllegalStateException e = new IllegalStateException();
+ payloadUploadDelegate.handleRequestError(e);
+
+ assertEquals(3, ((MockUploader) batchingUploader).failedRecords.size());
+ assertEquals(e, ((MockUploader) batchingUploader).failedRecords.get("testGuid1"));
+ assertEquals(e, ((MockUploader) batchingUploader).failedRecords.get("testGuid2"));
+ assertEquals(e, ((MockUploader) batchingUploader).failedRecords.get("testGuid3"));
+ assertFalse(((MockUploader) batchingUploader).didLastPayloadFail);
+
+ payloadUploadDelegate = new PayloadUploadDelegate(batchingUploader, postedGuids, false, true);
+ payloadUploadDelegate.handleRequestError(e);
+ assertEquals(3, ((MockUploader) batchingUploader).failedRecords.size());
+ assertTrue(((MockUploader) batchingUploader).didLastPayloadFail);
+ }
+
+ @Test
+ public void testHandleRequestFailure() {
+ ArrayList<String> postedGuids = new ArrayList<>(3);
+ postedGuids.add("testGuid1");
+ postedGuids.add("testGuid2");
+ postedGuids.add("testGuid3");
+ PayloadUploadDelegate payloadUploadDelegate = new PayloadUploadDelegate(batchingUploader, postedGuids, false, false);
+
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+ payloadUploadDelegate.handleRequestFailure(new SyncStorageResponse(response));
+ assertEquals(3, ((MockUploader) batchingUploader).failedRecords.size());
+ assertEquals(HTTPFailureException.class,
+ ((MockUploader) batchingUploader).failedRecords.get("testGuid1").getClass());
+ assertEquals(HTTPFailureException.class,
+ ((MockUploader) batchingUploader).failedRecords.get("testGuid2").getClass());
+ assertEquals(HTTPFailureException.class,
+ ((MockUploader) batchingUploader).failedRecords.get("testGuid3").getClass());
+
+ payloadUploadDelegate = new PayloadUploadDelegate(batchingUploader, postedGuids, false, true);
+ payloadUploadDelegate.handleRequestFailure(new SyncStorageResponse(response));
+ assertEquals(3, ((MockUploader) batchingUploader).failedRecords.size());
+ assertTrue(((MockUploader) batchingUploader).didLastPayloadFail);
+ }
+
+ @Test
+ public void testIfUnmodifiedSince() {
+ PayloadUploadDelegate payloadUploadDelegate = new PayloadUploadDelegate(
+ batchingUploader, new ArrayList<String>(), false, false);
+
+ assertNull(payloadUploadDelegate.ifUnmodifiedSince());
+
+ try {
+ batchingUploader.getCurrentBatch().setLastModified(1471645412480L, true);
+ } catch (BatchingUploader.BatchingUploaderException e) {}
+
+ assertEquals("1471645412.480", payloadUploadDelegate.ifUnmodifiedSince());
+ }
+
+ private SyncStorageResponse makeSyncStorageResponse(int code, String body, String lastModified) {
+ BasicHttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), code, null));
+
+ if (body != null) {
+ BasicHttpEntity entity = new BasicHttpEntity();
+ entity.setContent(new ByteArrayInputStream(body.getBytes()));
+ response.setEntity(entity);
+ }
+
+ if (lastModified != null) {
+ response.addHeader(SyncResponse.X_LAST_MODIFIED, lastModified);
+ }
+ return new SyncStorageResponse(response);
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/RecordUploadRunnableTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/RecordUploadRunnableTest.java
new file mode 100644
index 000000000..269c25362
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/RecordUploadRunnableTest.java
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.repositories.uploaders;
+
+import android.net.Uri;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import java.net.URI;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class RecordUploadRunnableTest {
+ @Test
+ public void testBuildPostURI() throws Exception {
+ BatchMeta batchMeta = new BatchMeta(new Object(), 1, 1, null);
+ URI postURI = RecordUploadRunnable.buildPostURI(
+ false, batchMeta, Uri.parse("http://example.com/"));
+ assertEquals("http://example.com/?batch=true", postURI.toString());
+
+ postURI = RecordUploadRunnable.buildPostURI(
+ true, batchMeta, Uri.parse("http://example.com/"));
+ assertEquals("http://example.com/?batch=true&commit=true", postURI.toString());
+
+ batchMeta.setToken("MTIzNA", false);
+ postURI = RecordUploadRunnable.buildPostURI(
+ false, batchMeta, Uri.parse("http://example.com/"));
+ assertEquals("http://example.com/?batch=MTIzNA", postURI.toString());
+
+ postURI = RecordUploadRunnable.buildPostURI(
+ true, batchMeta, Uri.parse("http://example.com/"));
+ assertEquals("http://example.com/?batch=MTIzNA&commit=true", postURI.toString());
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestEnsureCrypto5KeysStage.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestEnsureCrypto5KeysStage.java
new file mode 100644
index 000000000..cb74b427b
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestEnsureCrypto5KeysStage.java
@@ -0,0 +1,237 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.stage.test;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockGlobalSessionCallback;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.MockGlobalSession;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.AlreadySyncingException;
+import org.mozilla.gecko.sync.CollectionKeys;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.stage.EnsureCrypto5KeysStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collection;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestEnsureCrypto5KeysStage {
+ private int TEST_PORT = HTTPServerTestHelper.getTestPort();
+ private final String TEST_CLUSTER_URL = "http://localhost:" + TEST_PORT;
+ private final String TEST_USERNAME = "johndoe";
+ private final String TEST_PASSWORD = "password";
+ private final String TEST_SYNC_KEY = "abcdeabcdeabcdeabcdeabcdea";
+
+ private final String TEST_JSON_NO_CRYPTO =
+ "{\"history\":1.3319567131E9}";
+ private final String TEST_JSON_OLD_CRYPTO =
+ "{\"history\":1.3319567131E9,\"crypto\":1.1E9}";
+ private final String TEST_JSON_NEW_CRYPTO =
+ "{\"history\":1.3319567131E9,\"crypto\":3.1E9}";
+
+ private HTTPServerTestHelper data = new HTTPServerTestHelper();
+
+ private KeyBundle syncKeyBundle;
+ private MockGlobalSessionCallback callback;
+ private GlobalSession session;
+
+ private boolean calledResetStages;
+ private Collection<String> stagesReset;
+
+ @Before
+ public void setUp() throws Exception {
+ syncKeyBundle = new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY);
+ callback = new MockGlobalSessionCallback();
+ session = new MockGlobalSession(TEST_USERNAME, TEST_PASSWORD,
+ syncKeyBundle, callback) {
+ @Override
+ protected void prepareStages() {
+ super.prepareStages();
+ withStage(Stage.ensureKeysStage, new EnsureCrypto5KeysStage());
+ }
+
+ @Override
+ public void resetStagesByEnum(Collection<Stage> stages) {
+ calledResetStages = true;
+ stagesReset = new ArrayList<String>();
+ for (Stage stage : stages) {
+ stagesReset.add(stage.name());
+ }
+ }
+
+ @Override
+ public void resetStagesByName(Collection<String> names) {
+ calledResetStages = true;
+ stagesReset = names;
+ }
+ };
+ session.config.setClusterURL(new URI(TEST_CLUSTER_URL));
+
+ // Set info collections to not have crypto.
+ final ExtendedJSONObject noCrypto = new ExtendedJSONObject(TEST_JSON_NO_CRYPTO);
+ session.config.infoCollections = new InfoCollections(noCrypto);
+ calledResetStages = false;
+ stagesReset = null;
+ }
+
+ public void doSession(MockServer server) {
+ data.startHTTPServer(server);
+ try {
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ session.start();
+ } catch (AlreadySyncingException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+ });
+ } finally {
+ data.stopHTTPServer();
+ }
+ }
+
+ @Test
+ public void testDownloadUsesPersisted() throws Exception {
+ session.config.infoCollections = new InfoCollections(new ExtendedJSONObject
+ (TEST_JSON_OLD_CRYPTO));
+ session.config.persistedCryptoKeys().persistLastModified(System.currentTimeMillis());
+
+ assertNull(session.config.collectionKeys);
+ final CollectionKeys keys = CollectionKeys.generateCollectionKeys();
+ keys.setDefaultKeyBundle(syncKeyBundle);
+ session.config.persistedCryptoKeys().persistKeys(keys);
+
+ MockServer server = new MockServer() {
+ public void handle(Request request, Response response) {
+ this.handle(request, response, 404, "should not be called!");
+ }
+ };
+
+ doSession(server);
+
+ assertTrue(callback.calledSuccess);
+ assertNotNull(session.config.collectionKeys);
+ assertTrue(CollectionKeys.differences(session.config.collectionKeys, keys).isEmpty());
+ }
+
+ @Test
+ public void testDownloadFetchesNew() throws Exception {
+ session.config.infoCollections = new InfoCollections(new ExtendedJSONObject(TEST_JSON_NEW_CRYPTO));
+ session.config.persistedCryptoKeys().persistLastModified(System.currentTimeMillis());
+
+ assertNull(session.config.collectionKeys);
+ final CollectionKeys keys = CollectionKeys.generateCollectionKeys();
+ keys.setDefaultKeyBundle(syncKeyBundle);
+ session.config.persistedCryptoKeys().persistKeys(keys);
+
+ MockServer server = new MockServer() {
+ public void handle(Request request, Response response) {
+ try {
+ CryptoRecord rec = keys.asCryptoRecord();
+ rec.keyBundle = syncKeyBundle;
+ rec.encrypt();
+ this.handle(request, response, 200, rec.toJSONString());
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ };
+
+ doSession(server);
+
+ assertTrue(callback.calledSuccess);
+ assertNotNull(session.config.collectionKeys);
+ assertTrue(session.config.collectionKeys.equals(keys));
+ }
+
+ /**
+ * Change the default key but keep one collection key the same. Should reset
+ * all but that one collection.
+ */
+ @Test
+ public void testDownloadResetsOnDifferentDefaultKey() throws Exception {
+ String TEST_COLLECTION = "bookmarks";
+
+ session.config.infoCollections = new InfoCollections(new ExtendedJSONObject(TEST_JSON_NEW_CRYPTO));
+ session.config.persistedCryptoKeys().persistLastModified(System.currentTimeMillis());
+
+ KeyBundle keyBundle = KeyBundle.withRandomKeys();
+ assertNull(session.config.collectionKeys);
+ final CollectionKeys keys = CollectionKeys.generateCollectionKeys();
+ keys.setKeyBundleForCollection(TEST_COLLECTION, keyBundle);
+ session.config.persistedCryptoKeys().persistKeys(keys);
+ keys.setDefaultKeyBundle(syncKeyBundle); // Change the default key bundle, but keep "bookmarks" the same.
+
+ MockServer server = new MockServer() {
+ public void handle(Request request, Response response) {
+ try {
+ CryptoRecord rec = keys.asCryptoRecord();
+ rec.keyBundle = syncKeyBundle;
+ rec.encrypt();
+ this.handle(request, response, 200, rec.toJSONString());
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ };
+
+ doSession(server);
+
+ assertTrue(calledResetStages);
+ Collection<String> allButCollection = new ArrayList<String>();
+ for (Stage stage : Stage.getNamedStages()) {
+ allButCollection.add(stage.getRepositoryName());
+ }
+ allButCollection.remove(TEST_COLLECTION);
+ assertTrue(stagesReset.containsAll(allButCollection));
+ assertTrue(allButCollection.containsAll(stagesReset));
+ assertTrue(callback.calledError);
+ }
+
+ @Test
+ public void testDownloadResetsEngineOnDifferentKey() throws Exception {
+ final String TEST_COLLECTION = "history";
+
+ session.config.infoCollections = new InfoCollections(new ExtendedJSONObject(TEST_JSON_NEW_CRYPTO));
+ session.config.persistedCryptoKeys().persistLastModified(System.currentTimeMillis());
+
+ assertNull(session.config.collectionKeys);
+ final CollectionKeys keys = CollectionKeys.generateCollectionKeys();
+ session.config.persistedCryptoKeys().persistKeys(keys);
+ keys.setKeyBundleForCollection(TEST_COLLECTION, syncKeyBundle); // Change one key bundle.
+
+ CryptoRecord rec = keys.asCryptoRecord();
+ rec.keyBundle = syncKeyBundle;
+ rec.encrypt();
+ MockServer server = new MockServer(200, rec.toJSONString());
+
+ doSession(server);
+
+ assertTrue(calledResetStages);
+ assertNotNull(stagesReset);
+ assertEquals(1, stagesReset.size());
+ assertTrue(stagesReset.contains(TEST_COLLECTION));
+ assertTrue(callback.calledError);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestFetchMetaGlobalStage.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestFetchMetaGlobalStage.java
new file mode 100644
index 000000000..f7ed7a559
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestFetchMetaGlobalStage.java
@@ -0,0 +1,391 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.stage.test;
+
+import org.json.simple.JSONArray;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.android.sync.net.test.TestMetaGlobal;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockGlobalSessionCallback;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.MockGlobalSession;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.AlreadySyncingException;
+import org.mozilla.gecko.sync.CollectionKeys;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.MetaGlobal;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.SyncConfigurationException;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.delegates.FreshStartDelegate;
+import org.mozilla.gecko.sync.delegates.KeyUploadDelegate;
+import org.mozilla.gecko.sync.delegates.WipeServerDelegate;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.stage.FetchMetaGlobalStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestFetchMetaGlobalStage {
+ @SuppressWarnings("unused")
+ private static final String LOG_TAG = "TestMetaGlobalStage";
+
+ private static final int TEST_PORT = HTTPServerTestHelper.getTestPort();
+ private static final String TEST_SERVER = "http://localhost:" + TEST_PORT + "/";
+ private static final String TEST_CLUSTER_URL = TEST_SERVER + "cluster/";
+ private HTTPServerTestHelper data = new HTTPServerTestHelper();
+
+ private final String TEST_USERNAME = "johndoe";
+ private final String TEST_PASSWORD = "password";
+ private final String TEST_SYNC_KEY = "abcdeabcdeabcdeabcdeabcdea";
+
+ private final String TEST_INFO_COLLECTIONS_JSON = "{}";
+
+ private static final String TEST_SYNC_ID = "testSyncID";
+ private static final long TEST_STORAGE_VERSION = GlobalSession.STORAGE_VERSION;
+
+ private InfoCollections infoCollections;
+ private KeyBundle syncKeyBundle;
+ private MockGlobalSessionCallback callback;
+ private GlobalSession session;
+
+ private boolean calledRequiresUpgrade = false;
+ private boolean calledProcessMissingMetaGlobal = false;
+ private boolean calledFreshStart = false;
+ private boolean calledWipeServer = false;
+ private boolean calledUploadKeys = false;
+ private boolean calledResetAllStages = false;
+
+ private static void assertSameContents(JSONArray expected, Set<String> actual) {
+ assertEquals(expected.size(), actual.size());
+ for (Object o : expected) {
+ assertTrue(actual.contains(o));
+ }
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ calledRequiresUpgrade = false;
+ calledProcessMissingMetaGlobal = false;
+ calledFreshStart = false;
+ calledWipeServer = false;
+ calledUploadKeys = false;
+ calledResetAllStages = false;
+
+ // Set info collections to not have crypto.
+ infoCollections = new InfoCollections(new ExtendedJSONObject(TEST_INFO_COLLECTIONS_JSON));
+
+ syncKeyBundle = new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY);
+ callback = new MockGlobalSessionCallback();
+ session = new MockGlobalSession(TEST_USERNAME, TEST_PASSWORD,
+ syncKeyBundle, callback) {
+ @Override
+ protected void prepareStages() {
+ super.prepareStages();
+ withStage(Stage.fetchMetaGlobal, new FetchMetaGlobalStage());
+ }
+
+ @Override
+ public void requiresUpgrade() {
+ calledRequiresUpgrade = true;
+ this.abort(null, "Requires upgrade");
+ }
+
+ @Override
+ public void processMissingMetaGlobal(MetaGlobal mg) {
+ calledProcessMissingMetaGlobal = true;
+ this.abort(null, "Missing meta/global");
+ }
+
+ // Don't really uploadKeys.
+ @Override
+ public void uploadKeys(CollectionKeys keys, KeyUploadDelegate keyUploadDelegate) {
+ calledUploadKeys = true;
+ keyUploadDelegate.onKeysUploaded();
+ }
+
+ // On fresh start completed, just stop.
+ @Override
+ public void freshStart() {
+ calledFreshStart = true;
+ freshStart(this, new FreshStartDelegate() {
+ @Override
+ public void onFreshStartFailed(Exception e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+
+ @Override
+ public void onFreshStart() {
+ WaitHelper.getTestWaiter().performNotify();
+ }
+ });
+ }
+
+ // Don't really wipeServer.
+ @Override
+ protected void wipeServer(final AuthHeaderProvider authHeaderProvider, final WipeServerDelegate wipeDelegate) {
+ calledWipeServer = true;
+ wipeDelegate.onWiped(System.currentTimeMillis());
+ }
+
+ // Don't really resetAllStages.
+ @Override
+ public void resetAllStages() {
+ calledResetAllStages = true;
+ }
+ };
+ session.config.setClusterURL(new URI(TEST_CLUSTER_URL));
+ session.config.infoCollections = infoCollections;
+ }
+
+ protected void doSession(MockServer server) {
+ data.startHTTPServer(server);
+ WaitHelper.getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ session.start();
+ } catch (AlreadySyncingException e) {
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+ }
+ }));
+ data.stopHTTPServer();
+ }
+
+ @Test
+ public void testFetchRequiresUpgrade() throws Exception {
+ MetaGlobal mg = new MetaGlobal(null, null);
+ mg.setSyncID(TEST_SYNC_ID);
+ mg.setStorageVersion(Long.valueOf(TEST_STORAGE_VERSION + 1));
+
+ MockServer server = new MockServer(200, mg.asCryptoRecord().toJSONString());
+ doSession(server);
+
+ assertEquals(true, callback.calledError);
+ assertTrue(calledRequiresUpgrade);
+ }
+
+ @SuppressWarnings("unchecked")
+ private JSONArray makeTestDeclinedArray() {
+ final JSONArray declined = new JSONArray();
+ declined.add("foobar");
+ return declined;
+ }
+
+ /**
+ * Verify that a fetched meta/global with remote syncID == local syncID does
+ * not reset.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testFetchSuccessWithSameSyncID() throws Exception {
+ session.config.syncID = TEST_SYNC_ID;
+
+ MetaGlobal mg = new MetaGlobal(null, null);
+ mg.setSyncID(TEST_SYNC_ID);
+ mg.setStorageVersion(Long.valueOf(TEST_STORAGE_VERSION));
+
+ // Set declined engines in the server object.
+ final JSONArray testingDeclinedEngines = makeTestDeclinedArray();
+ mg.setDeclinedEngineNames(testingDeclinedEngines);
+
+ MockServer server = new MockServer(200, mg.asCryptoRecord().toJSONString());
+ doSession(server);
+
+ assertTrue(callback.calledSuccess);
+ assertFalse(calledProcessMissingMetaGlobal);
+ assertFalse(calledResetAllStages);
+ assertEquals(TEST_SYNC_ID, session.config.metaGlobal.getSyncID());
+ assertEquals(TEST_STORAGE_VERSION, session.config.metaGlobal.getStorageVersion().longValue());
+ assertEquals(TEST_SYNC_ID, session.config.syncID);
+
+ // Declined engines propagate from the server meta/global.
+ final Set<String> actual = session.config.metaGlobal.getDeclinedEngineNames();
+ assertSameContents(testingDeclinedEngines, actual);
+ }
+
+ /**
+ * Verify that a fetched meta/global with remote syncID != local syncID resets
+ * local and retains remote syncID.
+ *
+ * @throws Exception
+ */
+ @Test
+ public void testFetchSuccessWithDifferentSyncID() throws Exception {
+ session.config.syncID = "NOT TEST SYNC ID";
+
+ MetaGlobal mg = new MetaGlobal(null, null);
+ mg.setSyncID(TEST_SYNC_ID);
+ mg.setStorageVersion(Long.valueOf(TEST_STORAGE_VERSION));
+
+ // Set declined engines in the server object.
+ final JSONArray testingDeclinedEngines = makeTestDeclinedArray();
+ mg.setDeclinedEngineNames(testingDeclinedEngines);
+
+ MockServer server = new MockServer(200, mg.asCryptoRecord().toJSONString());
+ doSession(server);
+
+ assertEquals(true, callback.calledSuccess);
+ assertFalse(calledProcessMissingMetaGlobal);
+ assertTrue(calledResetAllStages);
+ assertEquals(TEST_SYNC_ID, session.config.metaGlobal.getSyncID());
+ assertEquals(TEST_STORAGE_VERSION, session.config.metaGlobal.getStorageVersion().longValue());
+ assertEquals(TEST_SYNC_ID, session.config.syncID);
+
+ // Declined engines propagate from the server meta/global.
+ final Set<String> actual = session.config.metaGlobal.getDeclinedEngineNames();
+ assertSameContents(testingDeclinedEngines, actual);
+ }
+
+ /**
+ * Verify that a fetched meta/global does not merge declined engines.
+ * TODO: eventually it should!
+ */
+ @SuppressWarnings("unchecked")
+ @Test
+ public void testFetchSuccessWithDifferentSyncIDMergesDeclined() throws Exception {
+ session.config.syncID = "NOT TEST SYNC ID";
+
+ // Fake the local declined engine names.
+ session.config.declinedEngineNames = new HashSet<String>();
+ session.config.declinedEngineNames.add("baznoo");
+
+ MetaGlobal mg = new MetaGlobal(null, null);
+ mg.setSyncID(TEST_SYNC_ID);
+ mg.setStorageVersion(Long.valueOf(TEST_STORAGE_VERSION));
+
+ // Set declined engines in the server object.
+ final JSONArray testingDeclinedEngines = makeTestDeclinedArray();
+ mg.setDeclinedEngineNames(testingDeclinedEngines);
+
+ MockServer server = new MockServer(200, mg.asCryptoRecord().toJSONString());
+ doSession(server);
+
+ // Declined engines propagate from the server meta/global, and are NOT merged.
+ final Set<String> expected = new HashSet<String>(testingDeclinedEngines);
+ // expected.add("baznoo"); // Not until we merge. Local is lost.
+
+ final Set<String> newDeclined = session.config.metaGlobal.getDeclinedEngineNames();
+ assertEquals(expected, newDeclined);
+ }
+
+ @Test
+ public void testFetchMissing() throws Exception {
+ MockServer server = new MockServer(404, "missing");
+ doSession(server);
+
+ assertEquals(true, callback.calledError);
+ assertTrue(calledProcessMissingMetaGlobal);
+ }
+
+ /**
+ * Empty payload object has no syncID or storageVersion and should call freshStart.
+ * @throws Exception
+ */
+ @Test
+ public void testFetchEmptyPayload() throws Exception {
+ MockServer server = new MockServer(200, TestMetaGlobal.TEST_META_GLOBAL_EMPTY_PAYLOAD_RESPONSE);
+ doSession(server);
+
+ assertTrue(calledFreshStart);
+ }
+
+ /**
+ * No payload means no syncID or storageVersion and therefore we should call freshStart.
+ * @throws Exception
+ */
+ @Test
+ public void testFetchNoPayload() throws Exception {
+ MockServer server = new MockServer(200, TestMetaGlobal.TEST_META_GLOBAL_NO_PAYLOAD_RESPONSE);
+ doSession(server);
+
+ assertTrue(calledFreshStart);
+ }
+
+ /**
+ * Malformed payload is a server response issue, not a meta/global record
+ * issue. This should error out of the sync.
+ * @throws Exception
+ */
+ @Test
+ public void testFetchMalformedPayload() throws Exception {
+ MockServer server = new MockServer(200, TestMetaGlobal.TEST_META_GLOBAL_MALFORMED_PAYLOAD_RESPONSE);
+ doSession(server);
+
+ assertEquals(true, callback.calledError);
+ assertEquals(NonObjectJSONException.class, callback.calledErrorException.getClass());
+ }
+
+ protected void doFreshStart(MockServer server) {
+ data.startHTTPServer(server);
+ WaitHelper.getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+ @Override
+ public void run() {
+ session.freshStart();
+ }
+ }));
+ data.stopHTTPServer();
+ }
+
+ @Test
+ public void testFreshStart() throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, CryptoException {
+ final AtomicBoolean mgUploaded = new AtomicBoolean(false);
+ final AtomicBoolean mgDownloaded = new AtomicBoolean(false);
+ final MetaGlobal uploadedMg = new MetaGlobal(null, null);
+
+ MockServer server = new MockServer() {
+ @Override
+ public void handle(Request request, Response response) {
+ if (request.getMethod().equals("PUT")) {
+ try {
+ ExtendedJSONObject body = new ExtendedJSONObject(request.getContent());
+ assertTrue(body.containsKey("payload"));
+ assertFalse(body.containsKey("default"));
+
+ CryptoRecord rec = CryptoRecord.fromJSONRecord(body);
+ uploadedMg.setFromRecord(rec);
+ mgUploaded.set(true);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ this.handle(request, response, 200, "success");
+ return;
+ }
+ if (mgUploaded.get()) {
+ // We shouldn't be trying to download anything after uploading meta/global.
+ mgDownloaded.set(true);
+ }
+ this.handle(request, response, 404, "missing");
+ }
+ };
+ doFreshStart(server);
+
+ assertTrue(this.calledFreshStart);
+ assertTrue(this.calledWipeServer);
+ assertTrue(this.calledUploadKeys);
+ assertTrue(mgUploaded.get());
+ assertFalse(mgDownloaded.get());
+ assertEquals(GlobalSession.STORAGE_VERSION, uploadedMg.getStorageVersion().longValue());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestStageLookup.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestStageLookup.java
new file mode 100644
index 000000000..86829844f
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestStageLookup.java
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.stage.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+
+@RunWith(TestRunner.class)
+public class TestStageLookup {
+
+ @Test
+ public void testStageLookupByName() {
+ Set<Stage> namedStages = new HashSet<Stage>(Stage.getNamedStages());
+ Set<Stage> expected = new HashSet<Stage>();
+ expected.add(Stage.syncClientsEngine);
+ expected.add(Stage.syncBookmarks);
+ expected.add(Stage.syncTabs);
+ expected.add(Stage.syncFormHistory);
+ expected.add(Stage.syncHistory);
+ expected.add(Stage.syncPasswords);
+
+ assertEquals(expected, namedStages);
+ assertEquals(Stage.syncClientsEngine, Stage.byName("clients"));
+ assertEquals(Stage.syncTabs, Stage.byName("tabs"));
+ assertEquals(Stage.syncBookmarks, Stage.byName("bookmarks"));
+ assertEquals(Stage.syncFormHistory, Stage.byName("forms"));
+ assertEquals(Stage.syncHistory, Stage.byName("history"));
+ assertEquals(Stage.syncPasswords, Stage.byName("passwords"));
+
+ assertEquals(null, Stage.byName("foobar"));
+ assertEquals(null, Stage.byName(null));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestExtendedJSONObject.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestExtendedJSONObject.java
new file mode 100644
index 000000000..cff9287df
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestExtendedJSONObject.java
@@ -0,0 +1,203 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.test;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+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 java.io.IOException;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.is;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+@RunWith(TestRunner.class)
+public class TestExtendedJSONObject {
+ public static String exampleJSON = "{\"modified\":1233702554.25,\"success\":[\"{GXS58IDC}12\",\"{GXS58IDC}13\",\"{GXS58IDC}15\",\"{GXS58IDC}16\",\"{GXS58IDC}18\",\"{GXS58IDC}19\"],\"failed\":{\"{GXS58IDC}11\":[\"invalid parentid\"],\"{GXS58IDC}14\":[\"invalid parentid\"],\"{GXS58IDC}17\":[\"invalid parentid\"],\"{GXS58IDC}20\":[\"invalid parentid\"]}}";
+ public static String exampleIntegral = "{\"modified\":1233702554,}";
+
+ @Test
+ public void testFractional() throws IOException, NonObjectJSONException {
+ ExtendedJSONObject o = new ExtendedJSONObject(exampleJSON);
+ assertTrue(o.containsKey("modified"));
+ assertTrue(o.containsKey("success"));
+ assertTrue(o.containsKey("failed"));
+ assertFalse(o.containsKey(" "));
+ assertFalse(o.containsKey(""));
+ assertFalse(o.containsKey("foo"));
+ assertTrue(o.get("modified") instanceof Number);
+ assertTrue(o.get("modified").equals(Double.parseDouble("1233702554.25")));
+ assertEquals(Long.valueOf(1233702554250L), o.getTimestamp("modified"));
+ assertEquals(null, o.getTimestamp("foo"));
+ }
+
+ @Test
+ public void testIntegral() throws IOException, NonObjectJSONException {
+ ExtendedJSONObject o = new ExtendedJSONObject(exampleIntegral);
+ assertTrue(o.containsKey("modified"));
+ assertFalse(o.containsKey("success"));
+ assertTrue(o.get("modified") instanceof Number);
+ assertTrue(o.get("modified").equals(Long.parseLong("1233702554")));
+ assertEquals(Long.valueOf(1233702554000L), o.getTimestamp("modified"));
+ assertEquals(null, o.getTimestamp("foo"));
+ }
+
+ @Test
+ public void testSafeInteger() {
+ ExtendedJSONObject o = new ExtendedJSONObject();
+ o.put("integer", Integer.valueOf(5));
+ o.put("string", "66");
+ o.put("object", new ExtendedJSONObject());
+ o.put("null", (JSONArray) null);
+
+ assertEquals(Integer.valueOf(5), o.getIntegerSafely("integer"));
+ assertEquals(Integer.valueOf(66), o.getIntegerSafely("string"));
+ assertNull(o.getIntegerSafely(null));
+ }
+
+ @Test
+ public void testParseJSONArray() throws Exception {
+ JSONArray result = ExtendedJSONObject.parseJSONArray("[0, 1, {\"test\": 2}]");
+ assertNotNull(result);
+
+ assertThat((Long) result.get(0), is(equalTo(0L)));
+ assertThat((Long) result.get(1), is(equalTo(1L)));
+ assertThat((Long) ((JSONObject) result.get(2)).get("test"), is(equalTo(2L)));
+ }
+
+ @Test
+ public void testBadParseJSONArray() throws Exception {
+ try {
+ ExtendedJSONObject.parseJSONArray("[0, ");
+ fail();
+ } catch (NonArrayJSONException e) {
+ // Do nothing.
+ }
+
+ try {
+ ExtendedJSONObject.parseJSONArray("{}");
+ fail();
+ } catch (NonArrayJSONException e) {
+ // Do nothing.
+ }
+ }
+
+ @Test
+ public void testParseUTF8AsJSONObject() throws Exception {
+ String TEST = "{\"key\":\"value\"}";
+
+ ExtendedJSONObject o = ExtendedJSONObject.parseUTF8AsJSONObject(TEST.getBytes("UTF-8"));
+ assertNotNull(o);
+ assertEquals("value", o.getString("key"));
+ }
+
+ @Test
+ public void testBadParseUTF8AsJSONObject() throws Exception {
+ try {
+ ExtendedJSONObject.parseUTF8AsJSONObject("{}".getBytes("UTF-16"));
+ fail();
+ } catch (NonObjectJSONException e) {
+ // Do nothing.
+ }
+
+ try {
+ ExtendedJSONObject.parseUTF8AsJSONObject("{".getBytes("UTF-8"));
+ fail();
+ } catch (NonObjectJSONException e) {
+ // Do nothing.
+ }
+ }
+
+ @Test
+ public void testHashCode() throws Exception {
+ ExtendedJSONObject o = new ExtendedJSONObject(exampleJSON);
+ assertEquals(o.hashCode(), o.hashCode());
+ ExtendedJSONObject p = new ExtendedJSONObject(exampleJSON);
+ assertEquals(o.hashCode(), p.hashCode());
+
+ ExtendedJSONObject q = new ExtendedJSONObject(exampleJSON);
+ q.put("modified", 0);
+ assertNotSame(o.hashCode(), q.hashCode());
+ }
+
+ @Test
+ public void testEquals() throws Exception {
+ ExtendedJSONObject o = new ExtendedJSONObject(exampleJSON);
+ ExtendedJSONObject p = new ExtendedJSONObject(exampleJSON);
+ assertEquals(o, p);
+
+ ExtendedJSONObject q = new ExtendedJSONObject(exampleJSON);
+ q.put("modified", 0);
+ assertNotSame(o, q);
+ assertNotEquals(o, q);
+ }
+
+ @Test
+ public void testGetBoolean() throws Exception {
+ ExtendedJSONObject o = new ExtendedJSONObject("{\"truekey\":true, \"falsekey\":false, \"stringkey\":\"string\"}");
+ assertEquals(true, o.getBoolean("truekey"));
+ assertEquals(false, o.getBoolean("falsekey"));
+ try {
+ o.getBoolean("stringkey");
+ fail();
+ } catch (Exception e) {
+ assertTrue(e instanceof ClassCastException);
+ }
+ assertEquals(null, o.getBoolean("missingkey"));
+ }
+
+ @Test
+ public void testNullLong() throws Exception {
+ ExtendedJSONObject o = new ExtendedJSONObject("{\"x\": null}");
+ Long x = o.getLong("x");
+ assertNull(x);
+
+ long y = o.getLong("x", 5L);
+ assertEquals(5L, y);
+ }
+
+ protected void assertException(ExtendedJSONObject o, String[] requiredFields, Class<?> requiredFieldClass) {
+ try {
+ o.throwIfFieldsMissingOrMisTyped(requiredFields, requiredFieldClass);
+ fail();
+ } catch (Exception e) {
+ assertTrue(e instanceof BadRequiredFieldJSONException);
+ }
+ }
+
+ @Test
+ public void testThrow() throws Exception {
+ ExtendedJSONObject o = new ExtendedJSONObject("{\"true\":true, \"false\":false, \"string\":\"string\", \"long\":40000000000, \"int\":40, \"nested\":{\"inner\":10}}");
+ o.throwIfFieldsMissingOrMisTyped(new String[] { "true", "false" }, Boolean.class);
+ o.throwIfFieldsMissingOrMisTyped(new String[] { "string" }, String.class);
+ o.throwIfFieldsMissingOrMisTyped(new String[] { "long" }, Long.class);
+ o.throwIfFieldsMissingOrMisTyped(new String[] { "int" }, Long.class);
+ o.throwIfFieldsMissingOrMisTyped(new String[] { "int" }, null);
+
+ // Perhaps a bit unexpected, but we'll document it here.
+ o.throwIfFieldsMissingOrMisTyped(new String[] { "nested" }, JSONObject.class);
+
+ // Should fail.
+ assertException(o, new String[] { "int" }, Integer.class); // Irritating, but...
+ assertException(o, new String[] { "long" }, Integer.class); // Ditto.
+ assertException(o, new String[] { "missing" }, String.class);
+ assertException(o, new String[] { "missing" }, null);
+ assertException(o, new String[] { "string", "int" }, String.class); // Irritating, but...
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestInfoCollections.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestInfoCollections.java
new file mode 100644
index 000000000..d850ccc56
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestInfoCollections.java
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.test;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.InfoCounts;
+import org.mozilla.gecko.sync.Utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Test both info/collections and info/collection_counts.
+ */
+@RunWith(TestRunner.class)
+public class TestInfoCollections {
+ public static final String TEST_COLLECTIONS_JSON =
+ "{\"history\":1.3319567131E9, " +
+ " \"bookmarks\":1.33195669592E9, " +
+ " \"prefs\":1.33115408641E9, " +
+ " \"crypto\":1.32046063664E9, " +
+ " \"meta\":1.321E9, " +
+ " \"forms\":1.33136685374E9, " +
+ " \"clients\":1.3313667619E9, " +
+ " \"tabs\":1.35E9" +
+ "}";
+
+
+ public static final String TEST_COUNTS_JSON =
+ "{\"passwords\": 390, " +
+ " \"clients\": 2, " +
+ " \"crypto\": 1, " +
+ " \"forms\": 1019, " +
+ " \"bookmarks\": 766, " +
+ " \"prefs\": 1, " +
+ " \"history\": 9278" +
+ "}";
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testSetCountsFromRecord() throws Exception {
+ InfoCounts infoCountsEmpty = new InfoCounts(new ExtendedJSONObject("{}"));
+ assertEquals(null, infoCountsEmpty.getCount("bookmarks"));
+
+ ExtendedJSONObject record = new ExtendedJSONObject(TEST_COUNTS_JSON);
+ InfoCounts infoCountsFull = new InfoCounts(record);
+ assertEquals(Integer.valueOf(766), infoCountsFull.getCount("bookmarks"));
+ assertEquals(null, infoCountsFull.getCount("notpresent"));
+ }
+
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testSetCollectionsFromRecord() throws Exception {
+ ExtendedJSONObject record = new ExtendedJSONObject(TEST_COLLECTIONS_JSON);
+ InfoCollections infoCollections = new InfoCollections(record);
+
+ assertEquals(Utils.decimalSecondsToMilliseconds(1.3319567131E9), infoCollections.getTimestamp("history").longValue());
+ assertEquals(Utils.decimalSecondsToMilliseconds(1.321E9), infoCollections.getTimestamp("meta").longValue());
+ assertEquals(Utils.decimalSecondsToMilliseconds(1.35E9), infoCollections.getTimestamp("tabs").longValue());
+ assertNull(infoCollections.getTimestamp("missing"));
+ }
+
+ @SuppressWarnings("static-method")
+ @Test
+ public void testUpdateNeeded() throws Exception {
+ ExtendedJSONObject record = new ExtendedJSONObject(TEST_COLLECTIONS_JSON);
+ InfoCollections infoCollections = new InfoCollections(record);
+
+ long none = -1;
+ long past = Utils.decimalSecondsToMilliseconds(1.3E9);
+ long same = Utils.decimalSecondsToMilliseconds(1.35E9);
+ long future = Utils.decimalSecondsToMilliseconds(1.4E9);
+
+
+ // Test with no local timestamp set.
+ assertTrue(infoCollections.updateNeeded("tabs", none));
+
+ // Test with local timestamp set in the past.
+ assertTrue(infoCollections.updateNeeded("tabs", past));
+
+ // Test with same timestamp.
+ assertFalse(infoCollections.updateNeeded("tabs", same));
+
+ // Test with local timestamp set in the future.
+ assertFalse(infoCollections.updateNeeded("tabs", future));
+
+ // Test with no collection.
+ assertTrue(infoCollections.updateNeeded("missing", none));
+ assertTrue(infoCollections.updateNeeded("missing", past));
+ assertTrue(infoCollections.updateNeeded("missing", same));
+ assertTrue(infoCollections.updateNeeded("missing", future));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestPersistedMetaGlobal.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestPersistedMetaGlobal.java
new file mode 100644
index 000000000..d1b6cadef
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestPersistedMetaGlobal.java
@@ -0,0 +1,105 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.test;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.MetaGlobal;
+import org.mozilla.gecko.sync.NoCollectionKeysSetException;
+import org.mozilla.gecko.sync.PersistedMetaGlobal;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestPersistedMetaGlobal {
+ MockSharedPreferences prefs = null;
+ private final String TEST_META_URL = "metaURL";
+ private final String TEST_CREDENTIALS = "credentials";
+
+ @Before
+ public void setUp() {
+ prefs = new MockSharedPreferences();
+ }
+
+ @Test
+ public void testPersistLastModified() throws CryptoException, NoCollectionKeysSetException {
+ long LAST_MODIFIED = System.currentTimeMillis();
+ PersistedMetaGlobal persisted = new PersistedMetaGlobal(prefs);
+
+ // Test fresh start.
+ assertEquals(-1, persisted.lastModified());
+
+ // Test persisting.
+ persisted.persistLastModified(LAST_MODIFIED);
+ assertEquals(LAST_MODIFIED, persisted.lastModified());
+
+ // Test clearing.
+ persisted.persistLastModified(0);
+ assertEquals(-1, persisted.lastModified());
+ }
+
+ @Test
+ public void testPersistMetaGlobal() throws Exception {
+ PersistedMetaGlobal persisted = new PersistedMetaGlobal(prefs);
+ AuthHeaderProvider authHeaderProvider = new BasicAuthHeaderProvider(TEST_CREDENTIALS);
+
+ // Test fresh start.
+ assertNull(persisted.metaGlobal(TEST_META_URL, authHeaderProvider));
+
+ // Test persisting.
+ String body = "{\"id\":\"global\",\"payload\":\"{\\\"syncID\\\":\\\"zPSQTm7WBVWB\\\",\\\"storageVersion\\\":5,\\\"engines\\\":{\\\"clients\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"fDg0MS5bDtV7\\\"},\\\"bookmarks\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"NNaQr6_F-9dm\\\"},\\\"forms\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"GXF29AFprnvc\\\"},\\\"history\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"av75g4vm-_rp\\\"},\\\"passwords\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"LT_ACGpuKZ6a\\\"},\\\"prefs\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"-3nsksP9wSAs\\\"},\\\"tabs\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"W4H5lOMChkYA\\\"}}}\",\"username\":\"5817483\",\"modified\":1.32046073744E9}";
+ MetaGlobal mg = new MetaGlobal(TEST_META_URL, authHeaderProvider);
+ mg.setFromRecord(CryptoRecord.fromJSONRecord(body));
+ persisted.persistMetaGlobal(mg);
+
+ MetaGlobal persistedGlobal = persisted.metaGlobal(TEST_META_URL, authHeaderProvider);
+ assertNotNull(persistedGlobal);
+ assertEquals("zPSQTm7WBVWB", persistedGlobal.getSyncID());
+ assertTrue(persistedGlobal.getEngines() instanceof ExtendedJSONObject);
+ assertEquals(Long.valueOf(5), persistedGlobal.getStorageVersion());
+
+ // Test clearing.
+ persisted.persistMetaGlobal(null);
+ assertNull(persisted.metaGlobal(null, null));
+ }
+
+ @Test
+ public void testPersistDeclinedEngines() throws Exception {
+ PersistedMetaGlobal persisted = new PersistedMetaGlobal(prefs);
+ AuthHeaderProvider authHeaderProvider = new BasicAuthHeaderProvider(TEST_CREDENTIALS);
+
+ // Test fresh start.
+ assertNull(persisted.metaGlobal(TEST_META_URL, authHeaderProvider));
+
+ // Test persisting.
+ String body = "{\"id\":\"global\",\"payload\":\"{\\\"declined\\\":[\\\"bookmarks\\\",\\\"addons\\\"],\\\"syncID\\\":\\\"zPSQTm7WBVWB\\\",\\\"storageVersion\\\":5,\\\"engines\\\":{\\\"clients\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"fDg0MS5bDtV7\\\"},,\\\"forms\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"GXF29AFprnvc\\\"},\\\"history\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"av75g4vm-_rp\\\"},\\\"passwords\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"LT_ACGpuKZ6a\\\"},\\\"prefs\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"-3nsksP9wSAs\\\"},\\\"tabs\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"W4H5lOMChkYA\\\"}}}\",\"username\":\"5817483\",\"modified\":1.32046073744E9}";
+ MetaGlobal mg = new MetaGlobal(TEST_META_URL, authHeaderProvider);
+ mg.setFromRecord(CryptoRecord.fromJSONRecord(body));
+ persisted.persistMetaGlobal(mg);
+
+ MetaGlobal persistedGlobal = persisted.metaGlobal(TEST_META_URL, authHeaderProvider);
+ assertNotNull(persistedGlobal);
+ Set<String> declined = persistedGlobal.getDeclinedEngineNames();
+ assertEquals(2, declined.size());
+ assertTrue(declined.contains("bookmarks"));
+ assertTrue(declined.contains("addons"));
+
+ // Test clearing.
+ persisted.persistMetaGlobal(null);
+ assertNull(persisted.metaGlobal(null, null));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSearchCountMeasurements.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSearchCountMeasurements.java
new file mode 100644
index 000000000..058461f8e
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSearchCountMeasurements.java
@@ -0,0 +1,161 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.telemetry.measurements;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+
+/**
+ * Tests for the class that stores search count measurements.
+ */
+@RunWith(TestRunner.class)
+public class TestSearchCountMeasurements {
+
+ private SharedPreferences sharedPrefs;
+
+ @Before
+ public void setUp() throws Exception {
+ sharedPrefs = RuntimeEnvironment.application.getSharedPreferences(
+ TestSearchCountMeasurements.class.getSimpleName(), Context.MODE_PRIVATE);
+ }
+
+ private void assertNewValueInsertedNoIncrementedValues(final int expectedKeyCount) {
+ assertEquals("Shared prefs key count has incremented", expectedKeyCount, sharedPrefs.getAll().size());
+ assertTrue("Shared prefs still contains non-incremented initial value", sharedPrefs.getAll().containsValue(1));
+ assertFalse("Shared prefs has not incremented any values", sharedPrefs.getAll().containsValue(2));
+ }
+
+ @Test
+ public void testIncrementSearchCanRecreateEngineAndWhere() throws Exception {
+ final String expectedIdentifier = "google";
+ final String expectedWhere = "suggestbar";
+
+ SearchCountMeasurements.incrementSearch(sharedPrefs, expectedIdentifier, expectedWhere);
+ assertFalse("Shared prefs has some values", sharedPrefs.getAll().isEmpty());
+ assertTrue("Shared prefs contains initial value", sharedPrefs.getAll().containsValue(1));
+
+ boolean foundEngine = false;
+ for (final String key : sharedPrefs.getAll().keySet()) {
+ // We could try to match the exact key, but that's more fragile.
+ if (key.contains(expectedIdentifier) && key.contains(expectedWhere)) {
+ foundEngine = true;
+ }
+ }
+ assertTrue("SharedPrefs keyset contains enough info to recreate engine & where", foundEngine);
+ }
+
+ @Test
+ public void testIncrementSearchCalledMultipleTimesSameEngine() throws Exception {
+ final String engineIdentifier = "whatever";
+ final String where = "wherever";
+
+ SearchCountMeasurements.incrementSearch(sharedPrefs, engineIdentifier, where);
+ assertFalse("Shared prefs has some values", sharedPrefs.getAll().isEmpty());
+ assertTrue("Shared prefs contains initial value", sharedPrefs.getAll().containsValue(1));
+
+ // The initial key count storage saves metadata so we can't verify the number of keys is only 1. However,
+ // we assume subsequent calls won't add additional metadata and use it to verify the key count.
+ final int keyCountAfterFirst = sharedPrefs.getAll().size();
+ for (int i = 2; i <= 3; ++i) {
+ SearchCountMeasurements.incrementSearch(sharedPrefs, engineIdentifier, where);
+ assertEquals("Shared prefs key count has not changed", keyCountAfterFirst, sharedPrefs.getAll().size());
+ assertTrue("Shared prefs incremented", sharedPrefs.getAll().containsValue(i));
+ }
+ }
+
+ @Test
+ public void testIncrementSearchCalledMultipleTimesSameEngineDifferentWhere() throws Exception {
+ final String engineIdenfitier = "whatever";
+
+ SearchCountMeasurements.incrementSearch(sharedPrefs, engineIdenfitier, "one place");
+ assertFalse("Shared prefs has some values", sharedPrefs.getAll().isEmpty());
+ assertTrue("Shared prefs contains initial value", sharedPrefs.getAll().containsValue(1));
+
+ // The initial key count storage saves metadata so we can't verify the number of keys is only 1. However,
+ // we assume subsequent calls won't add additional metadata and use it to verify the key count.
+ final int keyCountAfterFirst = sharedPrefs.getAll().size();
+ for (int i = 1; i <= 2; ++i) {
+ SearchCountMeasurements.incrementSearch(sharedPrefs, engineIdenfitier, "another place " + i);
+ assertNewValueInsertedNoIncrementedValues(keyCountAfterFirst + i);
+ }
+ }
+
+ @Test
+ public void testIncrementSearchCalledMultipleTimesDifferentEngines() throws Exception {
+ final String where = "wherever";
+
+ SearchCountMeasurements.incrementSearch(sharedPrefs, "steam engine", where);
+ assertFalse("Shared prefs has some values", sharedPrefs.getAll().isEmpty());
+ assertTrue("Shared prefs contains initial value", sharedPrefs.getAll().containsValue(1));
+
+ // The initial key count storage saves metadata so we can't verify the number of keys is only 1. However,
+ // we assume subsequent calls won't add additional metadata and use it to verify the key count.
+ final int keyCountAfterFirst = sharedPrefs.getAll().size();
+ for (int i = 1; i <= 2; ++i) {
+ SearchCountMeasurements.incrementSearch(sharedPrefs, "combustion engine" + i, where);
+ assertNewValueInsertedNoIncrementedValues(keyCountAfterFirst + i);
+ }
+ }
+
+ @Test // assumes the format saved in SharedPrefs to store test data
+ public void testGetAndZeroSearchDeletesPrefs() throws Exception {
+ assertTrue("Shared prefs is empty", sharedPrefs.getAll().isEmpty());
+
+ final SharedPreferences.Editor editor = sharedPrefs.edit();
+ final Set<String> engineKeys = new HashSet<>(Arrays.asList("whatever.yeah", "lol.what"));
+ editor.putStringSet(SearchCountMeasurements.PREF_SEARCH_KEYSET, engineKeys);
+ for (final String key : engineKeys) {
+ editor.putInt(getEngineSearchCountKey(key), 1);
+ }
+ editor.apply();
+ assertFalse("Shared prefs is not empty after test data inserted", sharedPrefs.getAll().isEmpty());
+
+ SearchCountMeasurements.getAndZeroSearch(sharedPrefs);
+ assertTrue("Shared prefs is empty after zero", sharedPrefs.getAll().isEmpty());
+ }
+
+ @Test // assumes the format saved in SharedPrefs to store test data
+ public void testGetAndZeroSearchVerifyReturnedData() throws Exception {
+ final HashMap<String, Integer> expected = new HashMap<>();
+ expected.put("steamengine.here", 1337);
+ expected.put("combustionengine.there", 10);
+
+ final SharedPreferences.Editor editor = sharedPrefs.edit();
+ editor.putStringSet(SearchCountMeasurements.PREF_SEARCH_KEYSET, expected.keySet());
+ for (final String key : expected.keySet()) {
+ editor.putInt(SearchCountMeasurements.getEngineSearchCountKey(key), expected.get(key));
+ }
+ editor.apply();
+ assertFalse("Shared prefs is not empty after test data inserted", sharedPrefs.getAll().isEmpty());
+
+ final ExtendedJSONObject actual = SearchCountMeasurements.getAndZeroSearch(sharedPrefs);
+ assertEquals("Returned JSON contains number of items inserted", expected.size(), actual.size());
+ for (final String key : expected.keySet()) {
+ assertEquals("Returned JSON contains inserted value", expected.get(key), (Integer) actual.getIntegerSafely(key));
+ }
+ }
+
+ @Test
+ public void testGetAndZeroSearchNoData() throws Exception {
+ final ExtendedJSONObject actual = SearchCountMeasurements.getAndZeroSearch(sharedPrefs);
+ assertEquals("Returned json is empty", 0, actual.size());
+ }
+
+ private String getEngineSearchCountKey(final String engineWhereStr) {
+ return SearchCountMeasurements.getEngineSearchCountKey(engineWhereStr);
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSessionMeasurements.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSessionMeasurements.java
new file mode 100644
index 000000000..a5d3ce551
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSessionMeasurements.java
@@ -0,0 +1,124 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.measurements;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.telemetry.measurements.SessionMeasurements.SessionMeasurementsContainer;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Tests the session measurements class.
+ */
+@RunWith(TestRunner.class)
+public class TestSessionMeasurements {
+
+ private SessionMeasurements testMeasurements;
+ private SharedPreferences sharedPrefs;
+ private Context context;
+
+ @Before
+ public void setUp() throws Exception {
+ testMeasurements = spy(SessionMeasurements.class);
+ sharedPrefs = RuntimeEnvironment.application.getSharedPreferences(
+ TestSessionMeasurements.class.getSimpleName(), Context.MODE_PRIVATE);
+ doReturn(sharedPrefs).when(testMeasurements).getSharedPreferences(any(Context.class));
+
+ context = RuntimeEnvironment.application;
+ }
+
+ private void assertSessionCount(final String postfix, final int expectedSessionCount) {
+ final int actual = sharedPrefs.getInt(SessionMeasurements.PREF_SESSION_COUNT, -1);
+ assertEquals("Expected number of sessions occurred " + postfix, expectedSessionCount, actual);
+ }
+
+ private void assertSessionDuration(final String postfix, final long expectedSessionDuration) {
+ final long actual = sharedPrefs.getLong(SessionMeasurements.PREF_SESSION_DURATION, -1);
+ assertEquals("Expected session duration received " + postfix, expectedSessionDuration, actual);
+ }
+
+ private void mockGetSystemTimeNanosToReturn(final long value) {
+ doReturn(value).when(testMeasurements).getSystemTimeNano();
+ }
+
+ @Test
+ public void testRecordSessionStartAndEndCalledOnce() throws Exception {
+ final long expectedElapsedSeconds = 4;
+ mockGetSystemTimeNanosToReturn(0);
+ testMeasurements.recordSessionStart();
+ mockGetSystemTimeNanosToReturn(TimeUnit.SECONDS.toNanos(expectedElapsedSeconds));
+ testMeasurements.recordSessionEnd(context);
+
+ final String postfix = "after recordSessionStart/End called once";
+ assertSessionCount(postfix, 1);
+ assertSessionDuration(postfix, expectedElapsedSeconds);
+ }
+
+ @Test
+ public void testRecordSessionStartAndEndCalledTwice() throws Exception {
+ final long expectedElapsedSeconds = 100;
+ mockGetSystemTimeNanosToReturn(0L);
+ for (int i = 1; i <= 2; ++i) {
+ testMeasurements.recordSessionStart();
+ mockGetSystemTimeNanosToReturn(TimeUnit.SECONDS.toNanos((expectedElapsedSeconds / 2) * i));
+ testMeasurements.recordSessionEnd(context);
+ }
+
+ final String postfix = "after recordSessionStart/End called twice";
+ assertSessionCount(postfix, 2);
+ assertSessionDuration(postfix, expectedElapsedSeconds);
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testRecordSessionStartThrowsIfSessionAlreadyStarted() throws Exception {
+ // First call will start the session, next expected to throw.
+ for (int i = 0; i < 2; ++i) {
+ testMeasurements.recordSessionStart();
+ }
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testRecordSessionEndThrowsIfCalledBeforeSessionStarted() {
+ testMeasurements.recordSessionEnd(context);
+ }
+
+ @Test // assumes the underlying format in SessionMeasurements
+ public void testGetAndResetSessionMeasurementsReturnsSetData() throws Exception {
+ final int expectedSessionCount = 42;
+ final long expectedSessionDuration = 1234567890;
+ sharedPrefs.edit()
+ .putInt(SessionMeasurements.PREF_SESSION_COUNT, expectedSessionCount)
+ .putLong(SessionMeasurements.PREF_SESSION_DURATION, expectedSessionDuration)
+ .apply();
+
+ final SessionMeasurementsContainer actual = testMeasurements.getAndResetSessionMeasurements(context);
+ assertEquals("Returned session count matches expected", expectedSessionCount, actual.sessionCount);
+ assertEquals("Returned session duration matches expected", expectedSessionDuration, actual.elapsedSeconds);
+ }
+
+ @Test
+ public void testGetAndResetSessionMeasurementsResetsData() throws Exception {
+ sharedPrefs.edit()
+ .putInt(SessionMeasurements.PREF_SESSION_COUNT, 10)
+ .putLong(SessionMeasurements.PREF_SESSION_DURATION, 10)
+ .apply();
+
+ testMeasurements.getAndResetSessionMeasurements(context);
+ final String postfix = "is reset after retrieval";
+ assertSessionCount(postfix, 0);
+ assertSessionDuration(postfix, 0);
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/pingbuilders/TestTelemetryPingBuilder.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/pingbuilders/TestTelemetryPingBuilder.java
new file mode 100644
index 000000000..ca0124121
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/pingbuilders/TestTelemetryPingBuilder.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.telemetry.pingbuilders;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit test methods of the {@link TelemetryPingBuilder} class.
+ */
+@RunWith(TestRunner.class)
+public class TestTelemetryPingBuilder {
+ @Test
+ public void testMandatoryFieldsNone() {
+ final NoMandatoryFieldsBuilder builder = new NoMandatoryFieldsBuilder();
+ builder.setNonMandatoryField();
+ assertNotNull("Builder does not throw and returns a non-null value", builder.build());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testMandatoryFieldsMissing() {
+ final MandatoryFieldsBuilder builder = new MandatoryFieldsBuilder();
+ builder.setNonMandatoryField()
+ .build(); // should throw
+ }
+
+ @Test
+ public void testMandatoryFieldsIncluded() {
+ final MandatoryFieldsBuilder builder = new MandatoryFieldsBuilder();
+ builder.setNonMandatoryField()
+ .setMandatoryField();
+ assertNotNull("Builder does not throw and returns non-null value", builder.build());
+ }
+
+ private static class NoMandatoryFieldsBuilder extends TelemetryPingBuilder {
+ @Override
+ public String getDocType() {
+ return "";
+ }
+
+ @Override
+ public String[] getMandatoryFields() {
+ return new String[0];
+ }
+
+ public NoMandatoryFieldsBuilder setNonMandatoryField() {
+ payload.put("non-mandatory", true);
+ return this;
+ }
+ }
+
+ private static class MandatoryFieldsBuilder extends TelemetryPingBuilder {
+ private static final String MANDATORY_FIELD = "mandatory-field";
+
+ @Override
+ public String getDocType() {
+ return "";
+ }
+
+ @Override
+ public String[] getMandatoryFields() {
+ return new String[] {
+ MANDATORY_FIELD,
+ };
+ }
+
+ public MandatoryFieldsBuilder setNonMandatoryField() {
+ payload.put("non-mandatory", true);
+ return this;
+ }
+
+ public MandatoryFieldsBuilder setMandatoryField() {
+ payload.put(MANDATORY_FIELD, true);
+ return this;
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/schedulers/TestTelemetryUploadAllPingsImmediatelyScheduler.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/schedulers/TestTelemetryUploadAllPingsImmediatelyScheduler.java
new file mode 100644
index 000000000..8093040ee
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/schedulers/TestTelemetryUploadAllPingsImmediatelyScheduler.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.telemetry.schedulers;
+
+import android.content.Context;
+import android.content.Intent;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.telemetry.TelemetryUploadService;
+import org.mozilla.gecko.telemetry.stores.TelemetryPingStore;
+
+import static junit.framework.Assert.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Unit tests for the upload immediately scheduler.
+ *
+ * When we add more schedulers, we'll likely change the interface
+ * (e.g. pass in current time) and these tests will be more useful.
+ */
+@RunWith(TestRunner.class)
+public class TestTelemetryUploadAllPingsImmediatelyScheduler {
+
+ private TelemetryUploadAllPingsImmediatelyScheduler testScheduler;
+ private TelemetryPingStore testStore;
+
+ @Before
+ public void setUp() {
+ testScheduler = new TelemetryUploadAllPingsImmediatelyScheduler();
+ testStore = mock(TelemetryPingStore.class);
+ }
+
+ @Test
+ public void testReadyToUpload() {
+ assertTrue("Scheduler is always ready to upload", testScheduler.isReadyToUpload(testStore));
+ }
+
+ @Test
+ public void testScheduleUpload() {
+ final Context context = mock(Context.class);
+
+ testScheduler.scheduleUpload(context, testStore);
+
+ final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
+ verify(context).startService(intentCaptor.capture());
+ final Intent actualIntent = intentCaptor.getValue();
+ assertEquals("Intent action is upload", TelemetryUploadService.ACTION_UPLOAD, actualIntent.getAction());
+ assertTrue("Intent contains store", actualIntent.hasExtra(TelemetryUploadService.EXTRA_STORE));
+ assertEquals("Intent class target is upload service",
+ TelemetryUploadService.class.getName(), actualIntent.getComponent().getClassName());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/stores/TestTelemetryJSONFilePingStore.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/stores/TestTelemetryJSONFilePingStore.java
new file mode 100644
index 000000000..a95a8b292
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/stores/TestTelemetryJSONFilePingStore.java
@@ -0,0 +1,250 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.stores;
+
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.telemetry.TelemetryPing;
+import org.mozilla.gecko.util.FileUtils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FilenameFilter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit test methods of the {@link TelemetryJSONFilePingStore} class.
+ */
+@RunWith(TestRunner.class)
+public class TestTelemetryJSONFilePingStore {
+
+ @Rule
+ public TemporaryFolder tempDir = new TemporaryFolder();
+ private File testDir;
+ private TelemetryJSONFilePingStore testStore;
+
+ @Before
+ public void setUp() throws Exception {
+ testDir = tempDir.newFolder();
+ testStore = new TelemetryJSONFilePingStore(testDir, "");
+ }
+
+ private ExtendedJSONObject generateTelemetryPayload() {
+ final ExtendedJSONObject out = new ExtendedJSONObject();
+ out.put("str", "a String");
+ out.put("int", 42);
+ out.put("null", (ExtendedJSONObject) null);
+ return out;
+ }
+
+ private void assertIsGeneratedPayload(final ExtendedJSONObject actual) throws Exception {
+ assertNull("Null field is null", actual.getObject("null"));
+ assertEquals("int field is correct", 42, (int) actual.getIntegerSafely("int"));
+ assertEquals("str field is correct", "a String", actual.getString("str"));
+ }
+
+ private void assertStoreFileCount(final int expectedCount) {
+ assertEquals("Store contains " + expectedCount + " item(s)", expectedCount, testDir.list().length);
+ }
+
+ @Test
+ public void testConstructorOnlyWritesToGivenDir() throws Exception {
+ // Constructor is called in @Before method
+ assertTrue("Store dir exists", testDir.exists());
+ assertEquals("Temp dir contains one dir (the store dir)", 1, tempDir.getRoot().list().length);
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testConstructorStoreAlreadyExistsAsNonDirectory() throws Exception {
+ final File file = tempDir.newFile();
+ new TelemetryJSONFilePingStore(file, "profileName"); // expected to throw.
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testConstructorDirIsNotReadable() throws Exception {
+ final File dir = tempDir.newFolder();
+ dir.setReadable(false);
+ new TelemetryJSONFilePingStore(dir, "profileName"); // expected to throw.
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testConstructorDirIsNotWritable() throws Exception {
+ final File dir = tempDir.newFolder();
+ dir.setWritable(false);
+ new TelemetryJSONFilePingStore(dir, "profileName"); // expected to throw.
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testConstructorDirIsNotExecutable() throws Exception {
+ final File dir = tempDir.newFolder();
+ dir.setExecutable(false);
+ new TelemetryJSONFilePingStore(dir, "profileName"); // expected to throw.
+ }
+
+ @Test
+ public void testStorePingStoresCorrectData() throws Exception {
+ assertStoreFileCount(0);
+
+ final String expectedID = getDocID();
+ final TelemetryPing expectedPing = new TelemetryPing("a/server/url", generateTelemetryPayload(), expectedID);
+ testStore.storePing(expectedPing);
+
+ assertStoreFileCount(1);
+ final String filename = testDir.list()[0];
+ assertTrue("Filename contains expected ID", filename.equals(expectedID));
+ final JSONObject actual = FileUtils.readJSONObjectFromFile(new File(testDir, filename));
+ assertEquals("Ping url paths are equal", expectedPing.getURLPath(), actual.getString(TelemetryJSONFilePingStore.KEY_URL_PATH));
+ assertIsGeneratedPayload(new ExtendedJSONObject(actual.getString(TelemetryJSONFilePingStore.KEY_PAYLOAD)));
+ }
+
+ @Test
+ public void testStorePingMultiplePingsStoreSeparateFiles() throws Exception {
+ assertStoreFileCount(0);
+ for (int i = 1; i < 10; ++i) {
+ testStore.storePing(new TelemetryPing("server", generateTelemetryPayload(), getDocID()));
+ assertStoreFileCount(i);
+ }
+ }
+
+ @Test
+ public void testStorePingReleasesFileLock() throws Exception {
+ assertStoreFileCount(0);
+ testStore.storePing(new TelemetryPing("server", generateTelemetryPayload(), getDocID()));
+ assertStoreFileCount(1);
+ final File file = new File(testDir, testDir.list()[0]);
+ final FileOutputStream stream = new FileOutputStream(file);
+ try {
+ assertNotNull("File lock is released after store write", stream.getChannel().tryLock());
+ } finally {
+ stream.close(); // releases lock
+ }
+ }
+
+ @Test
+ public void testGetAllPingsSavesData() throws Exception {
+ final String urlPrefix = "url";
+ writeTestPingsToStore(3, urlPrefix);
+
+ final ArrayList<TelemetryPing> pings = testStore.getAllPings();
+ for (final TelemetryPing ping : pings) {
+ assertEquals("Expected url path value received", urlPrefix + ping.getDocID(), ping.getURLPath());
+ assertIsGeneratedPayload(ping.getPayload());
+ }
+ }
+
+ @Test
+ public void testGetAllPingsIsSorted() throws Exception {
+ final List<String> storedDocIDs = writeTestPingsToStore(3, "urlPrefix");
+
+ final ArrayList<TelemetryPing> pings = testStore.getAllPings();
+ for (int i = 0; i < pings.size(); ++i) {
+ final String expectedDocID = storedDocIDs.get(i);
+ final TelemetryPing ping = pings.get(i);
+
+ assertEquals("Stored ping " + i + " retrieved in order", expectedDocID, ping.getDocID());
+ }
+ }
+
+ @Test // regression test: bug 1272817
+ public void testGetAllPingsHandlesEmptyFiles() throws Exception {
+ final int expectedPingCount = 3;
+ writeTestPingsToStore(expectedPingCount, "whatever");
+ assertTrue("Empty file is created", testStore.getPingFile(getDocID()).createNewFile());
+ assertEquals("Returned pings only contains valid files", expectedPingCount, testStore.getAllPings().size());
+ }
+
+ @Test
+ public void testMaybePrunePingsDoesNothingIfAtMax() throws Exception {
+ final int pingCount = TelemetryJSONFilePingStore.MAX_PING_COUNT;
+ writeTestPingsToStore(pingCount, "whatever");
+ assertStoreFileCount(pingCount);
+ testStore.maybePrunePings();
+ assertStoreFileCount(pingCount);
+ }
+
+ @Test
+ public void testMaybePrunePingsPrunesIfAboveMax() throws Exception {
+ final int pingCount = TelemetryJSONFilePingStore.MAX_PING_COUNT + 1;
+ final List<String> expectedDocIDs = writeTestPingsToStore(pingCount, "whatever");
+ assertStoreFileCount(pingCount);
+ testStore.maybePrunePings();
+ assertStoreFileCount(TelemetryJSONFilePingStore.MAX_PING_COUNT);
+
+ final HashSet<String> existingIDs = new HashSet<>(Arrays.asList(testDir.list()));
+ assertFalse("Oldest ping was removed", existingIDs.contains(expectedDocIDs.get(0)));
+ }
+
+ @Test
+ public void testOnUploadAttemptCompleted() throws Exception {
+ final List<String> savedDocIDs = writeTestPingsToStore(10, "url");
+ final int halfSize = savedDocIDs.size() / 2;
+ final Set<String> unuploadedPingIDs = new HashSet<>(savedDocIDs.subList(0, halfSize));
+ final Set<String> removedPingIDs = new HashSet<>(savedDocIDs.subList(halfSize, savedDocIDs.size()));
+ testStore.onUploadAttemptComplete(removedPingIDs);
+
+ for (final String unuploadedDocID : testDir.list()) {
+ assertFalse("Unuploaded ID is not in removed ping IDs", removedPingIDs.contains(unuploadedDocID));
+ assertTrue("Unuploaded ID is in unuploaded ping IDs", unuploadedPingIDs.contains(unuploadedDocID));
+ unuploadedPingIDs.remove(unuploadedDocID);
+ }
+ assertTrue("All non-successful-upload ping IDs were matched", unuploadedPingIDs.isEmpty());
+ }
+
+ @Test
+ public void testGetPingFileIsDocID() throws Exception {
+ final String docID = getDocID();
+ final File file = testStore.getPingFile(docID);
+ assertTrue("Ping filename contains ID", file.getName().equals(docID));
+ }
+
+ /**
+ * Writes pings to store without using store API with:
+ * server = urlPrefix + docID
+ * payload = generated payload
+ *
+ * The docID is stored as the filename.
+ *
+ * Note: assumes {@link TelemetryJSONFilePingStore#getPingFile(String)} works.
+ *
+ * @return a list of doc IDs saved to disk in ascending order of last modified date
+ */
+ private List<String> writeTestPingsToStore(final int count, final String urlPrefix) throws Exception {
+ final List<String> savedDocIDs = new ArrayList<>(count);
+ final long now = System.currentTimeMillis();
+ for (int i = 1; i <= count; ++i) {
+ final String docID = getDocID();
+ final JSONObject obj = new JSONObject()
+ .put(TelemetryJSONFilePingStore.KEY_URL_PATH, urlPrefix + docID)
+ .put(TelemetryJSONFilePingStore.KEY_PAYLOAD, generateTelemetryPayload());
+ final File pingFile = testStore.getPingFile(docID);
+ FileUtils.writeJSONObjectToFile(pingFile, obj);
+
+ // If we don't set an explicit time, the modified times are all equal.
+ // Also, last modified times are rounded by second.
+ assertTrue("Able to set last modified time", pingFile.setLastModified(now - (count * 10_000) + i * 10_000));
+ savedDocIDs.add(docID);
+ }
+ return savedDocIDs;
+ }
+
+ private String getDocID() {
+ return UUID.randomUUID().toString();
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/tokenserver/test/TestTokenServerClient.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/tokenserver/test/TestTokenServerClient.java
new file mode 100644
index 000000000..03fe67794
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/tokenserver/test/TestTokenServerClient.java
@@ -0,0 +1,335 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.tokenserver.test;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.client.methods.HttpGet;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.entity.StringEntity;
+import ch.boye.httpclientandroidlib.message.BasicHeader;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+import ch.boye.httpclientandroidlib.message.BasicStatusLine;
+import junit.framework.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.common.log.writers.StringLogWriter;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+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.SyncResponse;
+import org.mozilla.gecko.tokenserver.TokenServerClient;
+import org.mozilla.gecko.tokenserver.TokenServerClient.TokenFetchResourceDelegate;
+import org.mozilla.gecko.tokenserver.TokenServerClientDelegate;
+import org.mozilla.gecko.tokenserver.TokenServerException;
+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 org.mozilla.gecko.tokenserver.TokenServerToken;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+@RunWith(TestRunner.class)
+public class TestTokenServerClient {
+ public static final String JSON = "application/json";
+ public static final String TEXT = "text/plain";
+
+ public static final String TEST_TOKEN_RESPONSE = "{\"api_endpoint\": \"https://stage-aitc1.services.mozilla.com/1.0/1659259\"," +
+ "\"duration\": 300," +
+ "\"id\": \"eySHORTENED\"," +
+ "\"key\": \"-plSHORTENED\"," +
+ "\"uid\": 1659259}";
+
+ public static final String TEST_CONDITIONS_RESPONSE = "{\"errors\":[{" +
+ "\"location\":\"header\"," +
+ "\"description\":\"Need to accept conditions\"," +
+ "\"condition_urls\":{\"tos\":\"http://url-to-tos.com\"}," +
+ "\"name\":\"X-Conditions-Accepted\"}]," +
+ "\"status\":\"error\"}";
+
+ public static final String TEST_ERROR_RESPONSE = "{\"status\": \"error\"," +
+ "\"errors\": [{\"location\": \"body\", \"name\": \"\", \"description\": \"Unauthorized EXTENDED\"}]}";
+
+ public static final String TEST_INVALID_TIMESTAMP_RESPONSE = "{\"status\": \"invalid-timestamp\", " +
+ "\"errors\": [{\"location\": \"body\", \"name\": \"\", \"description\": \"Unauthorized\"}]}";
+
+ protected TokenServerClient client;
+
+ @Before
+ public void setUp() throws Exception {
+ this.client = new TokenServerClient(new URI("http://unused.com"), Executors.newSingleThreadExecutor());
+ }
+
+ protected TokenServerToken doProcessResponse(int statusCode, String contentType, Object token)
+ throws UnsupportedEncodingException, TokenServerException {
+ final HttpResponse response = new BasicHttpResponse(
+ new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), statusCode, "OK"));
+
+ StringEntity stringEntity = new StringEntity(token.toString());
+ stringEntity.setContentType(contentType);
+ response.setEntity(stringEntity);
+
+ return client.processResponse(new SyncResponse(response));
+ }
+
+ @SuppressWarnings("rawtypes")
+ protected TokenServerException expectProcessResponseFailure(int statusCode, String contentType, Object token, Class klass)
+ throws TokenServerException, UnsupportedEncodingException {
+ try {
+ doProcessResponse(statusCode, contentType, token.toString());
+ fail("Expected exception of type " + klass + ".");
+
+ return null;
+ } catch (TokenServerException e) {
+ assertEquals(klass, e.getClass());
+
+ return e;
+ }
+ }
+
+ @SuppressWarnings("rawtypes")
+ protected TokenServerException expectProcessResponseFailure(Object token, Class klass)
+ throws UnsupportedEncodingException, TokenServerException {
+ return expectProcessResponseFailure(200, "application/json", token, klass);
+ }
+
+ @Test
+ public void testProcessResponseSuccess() throws Exception {
+ TokenServerToken token = doProcessResponse(200, "application/json", TEST_TOKEN_RESPONSE);
+
+ assertEquals("eySHORTENED", token.id);
+ assertEquals("-plSHORTENED", token.key);
+ assertEquals("1659259", token.uid);
+ assertEquals("https://stage-aitc1.services.mozilla.com/1.0/1659259", token.endpoint);
+ }
+
+ @Test
+ public void testProcessResponseFailure() throws Exception {
+ // Wrong Content-Type.
+ expectProcessResponseFailure(200, TEXT, new ExtendedJSONObject(), TokenServerMalformedResponseException.class);
+
+ // Not valid JSON.
+ expectProcessResponseFailure("#!", TokenServerMalformedResponseException.class);
+
+ // Status code 400.
+ expectProcessResponseFailure(400, JSON, new ExtendedJSONObject(), TokenServerMalformedRequestException.class);
+
+ // Status code 401.
+ expectProcessResponseFailure(401, JSON, new ExtendedJSONObject(), TokenServerInvalidCredentialsException.class);
+ expectProcessResponseFailure(401, JSON, TEST_INVALID_TIMESTAMP_RESPONSE, TokenServerInvalidCredentialsException.class);
+
+ // Status code 404.
+ expectProcessResponseFailure(404, JSON, new ExtendedJSONObject(), TokenServerUnknownServiceException.class);
+
+ // Status code 406, which is not specially handled, but with errors. We take
+ // care that errors are actually printed because we're going to want this to
+ // work when things go wrong.
+ StringLogWriter logWriter = new StringLogWriter();
+
+ Logger.startLoggingTo(logWriter);
+ try {
+ expectProcessResponseFailure(406, JSON, TEST_ERROR_RESPONSE, TokenServerException.class);
+
+ assertTrue(logWriter.toString().contains("Unauthorized EXTENDED"));
+ } finally {
+ Logger.stopLoggingTo(logWriter);
+ }
+
+ // Status code 503.
+ expectProcessResponseFailure(503, JSON, new ExtendedJSONObject(), TokenServerException.class);
+ }
+
+ @Test
+ public void testProcessResponseConditionsRequired() throws Exception {
+
+ // Status code 403: conditions need to be accepted, but malformed (no urls).
+ expectProcessResponseFailure(403, JSON, new ExtendedJSONObject(), TokenServerMalformedResponseException.class);
+
+ // Status code 403, with urls.
+ TokenServerConditionsRequiredException e = (TokenServerConditionsRequiredException)
+ expectProcessResponseFailure(403, JSON, TEST_CONDITIONS_RESPONSE, TokenServerConditionsRequiredException.class);
+
+ ExtendedJSONObject expectedUrls = new ExtendedJSONObject();
+ expectedUrls.put("tos", "http://url-to-tos.com");
+ assertEquals(expectedUrls.toString(), e.conditionUrls.toString());
+ }
+
+ @Test
+ public void testProcessResponseMalformedToken() throws Exception {
+ ExtendedJSONObject token;
+
+ // Missing key.
+ token = new ExtendedJSONObject(TEST_TOKEN_RESPONSE);
+ token.remove("api_endpoint");
+ expectProcessResponseFailure(token, TokenServerMalformedResponseException.class);
+
+ // Key has wrong type; expected String.
+ token = new ExtendedJSONObject(TEST_TOKEN_RESPONSE);
+ token.put("api_endpoint", new ExtendedJSONObject());
+ expectProcessResponseFailure(token, TokenServerMalformedResponseException.class);
+
+ // Key has wrong type; expected number.
+ token = new ExtendedJSONObject(TEST_TOKEN_RESPONSE);
+ token.put("uid", "NON NUMERIC");
+ expectProcessResponseFailure(token, TokenServerMalformedResponseException.class);
+ }
+
+ private class MockBaseResource extends BaseResource {
+ public MockBaseResource(String uri) throws URISyntaxException {
+ super(uri);
+ this.request = new HttpGet(this.uri);
+ }
+
+ public HttpRequestBase prepareHeadersAndReturn() throws Exception {
+ super.prepareClient();
+ return request;
+ }
+ }
+
+ @Test
+ public void testClientStateHeader() throws Exception {
+ String assertion = "assertion";
+ String clientState = "abcdef";
+ MockBaseResource resource = new MockBaseResource("http://unused.local/");
+
+ TokenServerClientDelegate delegate = new TokenServerClientDelegate() {
+ @Override
+ public void handleSuccess(TokenServerToken token) {
+ }
+
+ @Override
+ public void handleFailure(TokenServerException e) {
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ }
+
+ @Override
+ public void handleBackoff(int backoffSeconds) {
+ }
+
+ @Override
+ public String getUserAgent() {
+ return null;
+ }
+ };
+
+ resource.delegate = new TokenServerClient.TokenFetchResourceDelegate(client, resource, delegate, assertion, clientState , true) {
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return null;
+ }
+ };
+
+ HttpRequestBase request = resource.prepareHeadersAndReturn();
+ Assert.assertEquals("abcdef", request.getFirstHeader("X-Client-State").getValue());
+ Assert.assertEquals("1", request.getFirstHeader("X-Conditions-Accepted").getValue());
+ }
+
+ public static class MockTokenServerClient extends TokenServerClient {
+ public MockTokenServerClient(URI uri, Executor executor) {
+ super(uri, executor);
+ }
+ }
+
+ public static final class MockTokenServerClientDelegate implements
+ TokenServerClientDelegate {
+ public volatile boolean backoffCalled;
+ public volatile boolean succeeded;
+ public volatile int backoff;
+
+ @Override
+ public String getUserAgent() {
+ return null;
+ }
+
+ @Override
+ public void handleSuccess(TokenServerToken token) {
+ succeeded = true;
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public void handleFailure(TokenServerException e) {
+ succeeded = false;
+ WaitHelper.getTestWaiter().performNotify();
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ succeeded = false;
+ WaitHelper.getTestWaiter().performNotify(e);
+ }
+
+ @Override
+ public void handleBackoff(int backoffSeconds) {
+ backoffCalled = true;
+ backoff = backoffSeconds;
+ }
+ }
+
+ private void expectDelegateCalls(URI uri, MockTokenServerClient client, int code, Header header, String body, boolean succeeded, long backoff, boolean expectBackoff) throws UnsupportedEncodingException {
+ final BaseResource resource = new BaseResource(uri);
+ final String assertion = "someassertion";
+ final String clientState = "abcdefabcdefabcdefabcdefabcdefab";
+ final boolean conditionsAccepted = true;
+
+ MockTokenServerClientDelegate delegate = new MockTokenServerClientDelegate();
+
+ final TokenFetchResourceDelegate tokenFetchResourceDelegate = new TokenServerClient.TokenFetchResourceDelegate(client, resource, delegate, assertion, clientState, conditionsAccepted);
+
+ final BasicStatusLine statusline = new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), code, "Whatever");
+ final HttpResponse response = new BasicHttpResponse(statusline);
+ response.setHeader(header);
+ if (body != null) {
+ final StringEntity entity = new StringEntity(body);
+ entity.setContentType("application/json");
+ response.setEntity(entity);
+ }
+
+ WaitHelper.getTestWaiter().performWait(new Runnable() {
+ @Override
+ public void run() {
+ tokenFetchResourceDelegate.handleHttpResponse(response);
+ }
+ });
+
+ assertEquals(expectBackoff, delegate.backoffCalled);
+ assertEquals(backoff, delegate.backoff);
+ assertEquals(succeeded, delegate.succeeded);
+ }
+
+ @Test
+ public void testBackoffHandling() throws URISyntaxException, UnsupportedEncodingException {
+ final URI uri = new URI("http://unused.com");
+ final MockTokenServerClient client = new MockTokenServerClient(uri, Executors.newSingleThreadExecutor());
+
+ // Even the 200 code here is false because the body is malformed.
+ expectDelegateCalls(uri, client, 200, new BasicHeader("x-backoff", "13"), "baaaa", false, 13, true);
+ expectDelegateCalls(uri, client, 400, new BasicHeader("X-Weave-Backoff", "15"), null, false, 15, true);
+ expectDelegateCalls(uri, client, 503, new BasicHeader("retry-after", "3"), null, false, 3, true);
+
+ // Retry-After is only processed on 503.
+ expectDelegateCalls(uri, client, 200, new BasicHeader("retry-after", "13"), null, false, 0, false);
+
+ // Now let's try one with a valid body.
+ expectDelegateCalls(uri, client, 200, new BasicHeader("X-Backoff", "1234"), TEST_TOKEN_RESPONSE, true, 1234, true);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/NetworkUtilsTest.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/NetworkUtilsTest.java
new file mode 100644
index 000000000..fb2cffc92
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/NetworkUtilsTest.java
@@ -0,0 +1,185 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.util;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.telephony.TelephonyManager;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.util.NetworkUtils.*;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.internal.ShadowExtractor;
+import org.robolectric.shadows.ShadowConnectivityManager;
+import org.robolectric.shadows.ShadowNetworkInfo;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class NetworkUtilsTest {
+ private ConnectivityManager connectivityManager;
+ private ShadowConnectivityManager shadowConnectivityManager;
+
+ @Before
+ public void setUp() {
+ connectivityManager = (ConnectivityManager) RuntimeEnvironment.application.getSystemService(Context.CONNECTIVITY_SERVICE);
+
+ // Not using Shadows.shadowOf(connectivityManager) because of Robolectric bug when using API23+
+ // See: https://github.com/robolectric/robolectric/issues/1862
+ shadowConnectivityManager = (ShadowConnectivityManager) ShadowExtractor.extract(connectivityManager);
+ }
+
+ @Test
+ public void testIsConnected() throws Exception {
+ assertFalse(NetworkUtils.isConnected((ConnectivityManager) null));
+
+ shadowConnectivityManager.setActiveNetworkInfo(null);
+ assertFalse(NetworkUtils.isConnected(connectivityManager));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_WIFI, 0, true, true)
+ );
+ assertTrue(NetworkUtils.isConnected(connectivityManager));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.DISCONNECTED, ConnectivityManager.TYPE_WIFI, 0, true, false)
+ );
+ assertFalse(NetworkUtils.isConnected(connectivityManager));
+ }
+
+ @Test
+ public void testGetConnectionSubType() throws Exception {
+ assertEquals(ConnectionSubType.UNKNOWN, NetworkUtils.getConnectionSubType(null));
+
+ shadowConnectivityManager.setActiveNetworkInfo(null);
+ assertEquals(ConnectionSubType.UNKNOWN, NetworkUtils.getConnectionSubType(connectivityManager));
+
+ // We don't seem to care about figuring out all connection types. So...
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_VPN, 0, true, true)
+ );
+ assertEquals(ConnectionSubType.UNKNOWN, NetworkUtils.getConnectionSubType(connectivityManager));
+
+ // But anything below we should recognize.
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_ETHERNET, 0, true, true)
+ );
+ assertEquals(ConnectionSubType.ETHERNET, NetworkUtils.getConnectionSubType(connectivityManager));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_WIFI, 0, true, true)
+ );
+ assertEquals(ConnectionSubType.WIFI, NetworkUtils.getConnectionSubType(connectivityManager));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_WIMAX, 0, true, true)
+ );
+ assertEquals(ConnectionSubType.WIMAX, NetworkUtils.getConnectionSubType(connectivityManager));
+
+ // Unknown mobile
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, TelephonyManager.NETWORK_TYPE_UNKNOWN, true, true)
+ );
+ assertEquals(ConnectionSubType.UNKNOWN, NetworkUtils.getConnectionSubType(connectivityManager));
+
+ // 2G mobile types
+ int[] cell2gTypes = new int[] {
+ TelephonyManager.NETWORK_TYPE_GPRS,
+ TelephonyManager.NETWORK_TYPE_EDGE,
+ TelephonyManager.NETWORK_TYPE_CDMA,
+ TelephonyManager.NETWORK_TYPE_1xRTT,
+ TelephonyManager.NETWORK_TYPE_IDEN
+ };
+ for (int i = 0; i < cell2gTypes.length; i++) {
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, cell2gTypes[i], true, true)
+ );
+ assertEquals(ConnectionSubType.CELL_2G, NetworkUtils.getConnectionSubType(connectivityManager));
+ }
+
+ // 3G mobile types
+ int[] cell3gTypes = new int[] {
+ TelephonyManager.NETWORK_TYPE_UMTS,
+ TelephonyManager.NETWORK_TYPE_EVDO_0,
+ TelephonyManager.NETWORK_TYPE_EVDO_A,
+ TelephonyManager.NETWORK_TYPE_HSDPA,
+ TelephonyManager.NETWORK_TYPE_HSUPA,
+ TelephonyManager.NETWORK_TYPE_HSPA,
+ TelephonyManager.NETWORK_TYPE_EVDO_B,
+ TelephonyManager.NETWORK_TYPE_EHRPD,
+ TelephonyManager.NETWORK_TYPE_HSPAP
+ };
+ for (int i = 0; i < cell3gTypes.length; i++) {
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, cell3gTypes[i], true, true)
+ );
+ assertEquals(ConnectionSubType.CELL_3G, NetworkUtils.getConnectionSubType(connectivityManager));
+ }
+
+ // 4G mobile type
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, TelephonyManager.NETWORK_TYPE_LTE, true, true)
+ );
+ assertEquals(ConnectionSubType.CELL_4G, NetworkUtils.getConnectionSubType(connectivityManager));
+ }
+
+ @Test
+ public void testGetConnectionType() {
+ shadowConnectivityManager.setActiveNetworkInfo(null);
+ assertEquals(ConnectionType.NONE, NetworkUtils.getConnectionType(connectivityManager));
+ assertEquals(ConnectionType.NONE, NetworkUtils.getConnectionType(null));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_VPN, 0, true, true)
+ );
+ assertEquals(ConnectionType.OTHER, NetworkUtils.getConnectionType(connectivityManager));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_WIFI, 0, true, true)
+ );
+ assertEquals(ConnectionType.WIFI, NetworkUtils.getConnectionType(connectivityManager));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, 0, true, true)
+ );
+ assertEquals(ConnectionType.CELLULAR, NetworkUtils.getConnectionType(connectivityManager));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_ETHERNET, 0, true, true)
+ );
+ assertEquals(ConnectionType.ETHERNET, NetworkUtils.getConnectionType(connectivityManager));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_BLUETOOTH, 0, true, true)
+ );
+ assertEquals(ConnectionType.BLUETOOTH, NetworkUtils.getConnectionType(connectivityManager));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_WIMAX, 0, true, true)
+ );
+ assertEquals(ConnectionType.CELLULAR, NetworkUtils.getConnectionType(connectivityManager));
+ }
+
+ @Test
+ public void testGetNetworkStatus() {
+ assertEquals(NetworkStatus.UNKNOWN, NetworkUtils.getNetworkStatus(null));
+
+ shadowConnectivityManager.setActiveNetworkInfo(null);
+ assertEquals(NetworkStatus.DOWN, NetworkUtils.getNetworkStatus(connectivityManager));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTING, ConnectivityManager.TYPE_MOBILE, 0, true, false)
+ );
+ assertEquals(NetworkStatus.DOWN, NetworkUtils.getNetworkStatus(connectivityManager));
+
+ shadowConnectivityManager.setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, 0, true, true)
+ );
+ assertEquals(NetworkStatus.UP, NetworkUtils.getNetworkStatus(connectivityManager));
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestContextUtils.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestContextUtils.java
new file mode 100644
index 000000000..56b69b684
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestContextUtils.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.util;
+
+import android.content.Context;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit test methods of the ContextUtils class.
+ */
+@RunWith(TestRunner.class)
+public class TestContextUtils {
+
+ private Context context;
+
+ @Before
+ public void setUp() {
+ context = RuntimeEnvironment.application;
+ }
+
+ @Test
+ public void testGetPackageInstallTimeReturnsReasonableValue() throws Exception {
+ // At the time of writing, Robolectric's value is 0, which is reasonable.
+ final long installTime = ContextUtils.getCurrentPackageInfo(context).firstInstallTime;
+ assertTrue("Package install time is positive", installTime >= 0);
+ assertTrue("Package install time is less than current time", installTime < System.currentTimeMillis());
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestDateUtil.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestDateUtil.java
new file mode 100644
index 000000000..a93c81ef0
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestDateUtil.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.util;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Unit tests for date utilities.
+ */
+@RunWith(TestRunner.class)
+public class TestDateUtil {
+ @Test
+ public void testGetDateInHTTPFormatGMT() {
+ final TimeZone gmt = TimeZone.getTimeZone("GMT");
+ final GregorianCalendar calendar = new GregorianCalendar(gmt, Locale.US);
+ calendar.set(2011, Calendar.FEBRUARY, 1, 14, 0, 0);
+ final String expectedDate = "Tue, 01 Feb 2011 14:00:00 GMT";
+
+ final String actualDate = DateUtil.getDateInHTTPFormat(calendar.getTime());
+ assertEquals("Returned date is expected", expectedDate, actualDate);
+ }
+
+ @Test
+ public void testGetDateInHTTPFormatNonGMT() {
+ final TimeZone kst = TimeZone.getTimeZone("Asia/Seoul"); // no daylight savings time.
+ final GregorianCalendar calendar = new GregorianCalendar(kst, Locale.US);
+ calendar.set(2011, Calendar.FEBRUARY, 1, 14, 0, 0);
+ final String expectedDate = "Tue, 01 Feb 2011 05:00:00 GMT";
+
+ final String actualDate = DateUtil.getDateInHTTPFormat(calendar.getTime());
+ assertEquals("Returned date is expected", expectedDate, actualDate);
+ }
+
+ @Test
+ public void testGetTimezoneOffsetInMinutes() {
+ assertEquals("GMT has no offset", 0, DateUtil.getTimezoneOffsetInMinutes(TimeZone.getTimeZone("GMT")));
+
+ // We use custom timezones because they don't have daylight savings time.
+ assertEquals("Offset for GMT-8 is correct",
+ -480, DateUtil.getTimezoneOffsetInMinutes(TimeZone.getTimeZone("GMT-8")));
+ assertEquals("Offset for GMT+12:45 is correct",
+ 765, DateUtil.getTimezoneOffsetInMinutes(TimeZone.getTimeZone("GMT+12:45")));
+
+ // We use a non-custom timezone without DST.
+ assertEquals("Offset for KST is correct",
+ 540, DateUtil.getTimezoneOffsetInMinutes(TimeZone.getTimeZone("Asia/Seoul")));
+ }
+
+ @Test
+ public void testGetTimezoneOffsetInMinutesForGivenDateNoDaylightSavingsTime() {
+ final TimeZone kst = TimeZone.getTimeZone("Asia/Seoul");
+ final Calendar[] calendars =
+ new Calendar[] { getCalendarForMonth(Calendar.DECEMBER), getCalendarForMonth(Calendar.AUGUST) };
+ for (final Calendar cal : calendars) {
+ cal.setTimeZone(kst);
+ assertEquals("Offset for KST does not change with daylight savings time",
+ 540, DateUtil.getTimezoneOffsetInMinutesForGivenDate(cal));
+ }
+ }
+
+ @Test
+ public void testGetTimezoneOffsetInMinutesForGivenDateDaylightSavingsTime() {
+ final TimeZone pacificTimeZone = TimeZone.getTimeZone("America/Los_Angeles");
+ final Calendar pstCalendar = getCalendarForMonth(Calendar.DECEMBER);
+ final Calendar pdtCalendar = getCalendarForMonth(Calendar.AUGUST);
+ pstCalendar.setTimeZone(pacificTimeZone);
+ pdtCalendar.setTimeZone(pacificTimeZone);
+ assertEquals("Offset for PST is correct", -480, DateUtil.getTimezoneOffsetInMinutesForGivenDate(pstCalendar));
+ assertEquals("Offset for PDT is correct", -420, DateUtil.getTimezoneOffsetInMinutesForGivenDate(pdtCalendar));
+
+ }
+
+ private Calendar getCalendarForMonth(final int month) {
+ return new GregorianCalendar(2000, month, 1);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestFileUtils.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestFileUtils.java
new file mode 100644
index 000000000..88fa7307d
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestFileUtils.java
@@ -0,0 +1,339 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.util;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.util.FileUtils.FileLastModifiedComparator;
+import org.mozilla.gecko.util.FileUtils.FilenameRegexFilter;
+import org.mozilla.gecko.util.FileUtils.FilenameWhitelistFilter;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import static junit.framework.Assert.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Tests the utilities in {@link FileUtils}.
+ */
+@RunWith(TestRunner.class)
+public class TestFileUtils {
+
+ private static final Charset CHARSET = Charset.forName("UTF-8");
+
+ @Rule
+ public TemporaryFolder tempDir = new TemporaryFolder();
+ public File testFile;
+ public File nonExistentFile;
+
+ @Before
+ public void setUp() throws Exception {
+ testFile = tempDir.newFile();
+ nonExistentFile = new File(tempDir.getRoot(), "non-existent-file");
+ }
+
+ @Test
+ public void testReadJSONObjectFromFile() throws Exception {
+ final JSONObject expected = new JSONObject("{\"str\": \"some str\"}");
+ writeStringToFile(testFile, expected.toString());
+
+ final JSONObject actual = FileUtils.readJSONObjectFromFile(testFile);
+ assertEquals("JSON contains expected str", expected.getString("str"), actual.getString("str"));
+ }
+
+ @Test(expected=IOException.class)
+ public void testReadJSONObjectFromFileEmptyFile() throws Exception {
+ assertEquals("Test file is empty", 0, testFile.length());
+ FileUtils.readJSONObjectFromFile(testFile); // expected to throw
+ }
+
+ @Test(expected=JSONException.class)
+ public void testReadJSONObjectFromFileInvalidJSON() throws Exception {
+ writeStringToFile(testFile, "not a json str");
+ FileUtils.readJSONObjectFromFile(testFile); // expected to throw
+ }
+
+ @Test
+ public void testReadStringFromFileReadsData() throws Exception {
+ final String expected = "String to write contains hard characters: !\n \\s..\"'\u00f1";
+ writeStringToFile(testFile, expected);
+
+ final String actual = FileUtils.readStringFromFile(testFile);
+ assertEquals("Read content matches written content", expected, actual);
+ }
+
+ @Test
+ public void testReadStringFromFileEmptyFile() throws Exception {
+ assertEquals("Test file is empty", 0, testFile.length());
+
+ final String actual = FileUtils.readStringFromFile(testFile);
+ assertEquals("Read content is empty", "", actual);
+ }
+
+ @Test(expected=FileNotFoundException.class)
+ public void testReadStringFromNonExistentFile() throws Exception {
+ assertFalse("File does not exist", nonExistentFile.exists());
+ FileUtils.readStringFromFile(nonExistentFile);
+ }
+
+ @Test
+ public void testReadStringFromInputStreamAndCloseStreamBufferLenIsFileLen() throws Exception {
+ final String expected = "String to write contains hard characters: !\n \\s..\"'\u00f1";
+ writeStringToFile(testFile, expected);
+
+ final FileInputStream stream = new FileInputStream(testFile);
+ final String actual = FileUtils.readStringFromInputStreamAndCloseStream(stream, expected.length());
+ assertEquals("Read content matches written content", expected, actual);
+ }
+
+ @Test
+ public void testReadStringFromInputStreamAndCloseStreamBufferLenIsBiggerThanFile() throws Exception {
+ final String expected = "aoeuhtns";
+ writeStringToFile(testFile, expected);
+
+ final FileInputStream stream = new FileInputStream(testFile);
+ final String actual = FileUtils.readStringFromInputStreamAndCloseStream(stream, expected.length() + 1024);
+ assertEquals("Read content matches written content", expected, actual);
+ }
+
+ @Test
+ public void testReadStringFromInputStreamAndCloseStreamBufferLenIsSmallerThanFile() throws Exception {
+ final String expected = "aoeuhtns aoeusth aoeusth aoeusnth aoeusth aoeusnth aoesuth";
+ writeStringToFile(testFile, expected);
+
+ final FileInputStream stream = new FileInputStream(testFile);
+ final String actual = FileUtils.readStringFromInputStreamAndCloseStream(stream, 8);
+ assertEquals("Read content matches written content", expected, actual);
+ }
+
+ @Test(expected=IllegalArgumentException.class)
+ public void testReadStringFromInputStreamAndCloseStreamBufferLenIsZero() throws Exception {
+ final String expected = "aoeuhtns aoeusth aoeusth aoeusnth aoeusth aoeusnth aoesuth";
+ writeStringToFile(testFile, expected);
+
+ final FileInputStream stream = new FileInputStream(testFile);
+ FileUtils.readStringFromInputStreamAndCloseStream(stream, 0); // expected to throw.
+ }
+
+ @Test
+ public void testReadStringFromInputStreamAndCloseStreamIsEmptyStream() throws Exception {
+ assertTrue("Test file exists", testFile.exists());
+ assertEquals("Test file is empty", 0, testFile.length());
+
+ final FileInputStream stream = new FileInputStream(testFile);
+ final String actual = FileUtils.readStringFromInputStreamAndCloseStream(stream, 8);
+ assertEquals("Read content from stream is empty", "", actual);
+ }
+
+ @Test(expected=IOException.class)
+ public void testReadStringFromInputStreamAndCloseStreamClosesStream() throws Exception {
+ final String expected = "String to write contains hard characters: !\n \\s..\"'\u00f1";
+ writeStringToFile(testFile, expected);
+
+ final FileInputStream stream = new FileInputStream(testFile);
+ try {
+ stream.read(); // should not throw because stream is open.
+ FileUtils.readStringFromInputStreamAndCloseStream(stream, expected.length());
+ } catch (final IOException e) {
+ fail("Did not expect method to throw when writing file: " + e);
+ }
+
+ stream.read(); // expected to throw because stream is closed.
+ }
+
+ @Test
+ public void testWriteStringToOutputStreamAndCloseStreamWritesData() throws Exception {
+ final String expected = "A string with some data in it! \u00f1 \n";
+ final FileOutputStream fos = new FileOutputStream(testFile, false);
+ FileUtils.writeStringToOutputStreamAndCloseStream(fos, expected);
+
+ assertTrue("Written file exists", testFile.exists());
+ assertEquals("Read data equals written data", expected, readStringFromFile(testFile, expected.length()));
+ }
+
+ @Test(expected=IOException.class)
+ public void testWriteStringToOutputStreamAndCloseStreamClosesStream() throws Exception {
+ final FileOutputStream fos = new FileOutputStream(testFile, false);
+ try {
+ fos.write('c'); // should not throw because stream is open.
+ FileUtils.writeStringToOutputStreamAndCloseStream(fos, "some string with data");
+ } catch (final IOException e) {
+ fail("Did not expect method to throw when writing file: " + e);
+ }
+
+ fos.write('c'); // expected to throw because stream is closed.
+ }
+
+ /**
+ * The Writer we wrap our stream in can throw in .close(), preventing the underlying stream from closing.
+ * I added code to prevent ensure we close if the writer .close() throws.
+ *
+ * I wrote this test to test that code, however, we'd have to mock the writer [1] and that isn't straight-forward.
+ * I left this test around because it's a good test of other code.
+ *
+ * [1]: We thought we could mock FileOutputStream.flush but it's only flushed if the Writer thinks it should be
+ * flushed. We can write directly to the Stream, but that doesn't change the Writer state and doesn't affect whether
+ * it thinks it should be flushed.
+ */
+ @Test(expected=IOException.class)
+ public void testWriteStringToOutputStreamAndCloseStreamClosesStreamIfWriterThrows() throws Exception {
+ final FileOutputStream fos = mock(FileOutputStream.class);
+ doThrow(IOException.class).when(fos).write(any(byte[].class), anyInt(), anyInt());
+ doThrow(IOException.class).when(fos).write(anyInt());
+ doThrow(IOException.class).when(fos).write(any(byte[].class));
+
+ boolean exceptionCaught = false;
+ try {
+ FileUtils.writeStringToOutputStreamAndCloseStream(fos, "some string with data");
+ } catch (final IOException e) {
+ exceptionCaught = true;
+ }
+ assertTrue("Exception caught during tested method", exceptionCaught); // not strictly necessary but documents assumptions
+
+ fos.write('c'); // expected to throw because stream is closed.
+ }
+
+ @Test
+ public void testWriteStringToFile() throws Exception {
+ final String expected = "String to write contains hard characters: !\n \\s..\"'\u00f1";
+ FileUtils.writeStringToFile(testFile, expected);
+
+ assertTrue("Written file exists", testFile.exists());
+ assertEquals("Read data equals written data", expected, readStringFromFile(testFile, expected.length()));
+ }
+
+ @Test
+ public void testWriteStringToFileEmptyString() throws Exception {
+ final String expected = "";
+ FileUtils.writeStringToFile(testFile, expected);
+
+ assertTrue("Written file exists", testFile.exists());
+ assertEquals("Written file is empty", 0, testFile.length());
+ assertEquals("Read data equals written (empty) data", expected, readStringFromFile(testFile, expected.length()));
+ }
+
+ @Test
+ public void testWriteStringToFileCreatesNewFile() throws Exception {
+ final String expected = "some str to write";
+ assertFalse("Non existent file does not exist", nonExistentFile.exists());
+ FileUtils.writeStringToFile(nonExistentFile, expected); // expected to create file
+
+ assertTrue("Written file was created", nonExistentFile.exists());
+ assertEquals("Read data equals written data", expected, readStringFromFile(nonExistentFile, (int) nonExistentFile.length()));
+ }
+
+ @Test
+ public void testWriteStringToFileOverwritesFile() throws Exception {
+ writeStringToFile(testFile, "data");
+
+ final String expected = "some str to write";
+ FileUtils.writeStringToFile(testFile, expected);
+
+ assertTrue("Written file was created", testFile.exists());
+ assertEquals("Read data equals written data", expected, readStringFromFile(testFile, (int) testFile.length()));
+ }
+
+ @Test
+ public void testWriteJSONObjectToFile() throws Exception {
+ final JSONObject expected = new JSONObject()
+ .put("int", 1)
+ .put("str", "1")
+ .put("bool", true)
+ .put("null", JSONObject.NULL)
+ .put("raw null", null);
+ FileUtils.writeJSONObjectToFile(testFile, expected);
+
+ assertTrue("Written file exists", testFile.exists());
+
+ // JSONObject.equals compares references so we have to assert each key individually. >:(
+ final JSONObject actual = new JSONObject(readStringFromFile(testFile, (int) testFile.length()));
+ assertEquals(1, actual.getInt("int"));
+ assertEquals("1", actual.getString("str"));
+ assertEquals(true, actual.getBoolean("bool"));
+ assertEquals(JSONObject.NULL, actual.get("null"));
+ assertFalse(actual.has("raw null"));
+ }
+
+ // Since the read methods may not be tested yet.
+ private static String readStringFromFile(final File file, final int bufferLen) throws IOException {
+ final char[] buffer = new char[bufferLen];
+ try (InputStreamReader reader = new InputStreamReader(new FileInputStream(file), Charset.forName("UTF-8"))) {
+ reader.read(buffer, 0, buffer.length);
+ }
+ return new String(buffer);
+ }
+
+ // Since the write methods may not be tested yet.
+ private static void writeStringToFile(final File file, final String str) throws IOException {
+ try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file, false), CHARSET)) {
+ writer.write(str);
+ }
+ assertTrue("Written file from helper method exists", file.exists());
+ }
+
+ @Test
+ public void testFilenameWhitelistFilter() {
+ final String[] expectedToAccept = new String[] { "one", "two", "three" };
+ final Set<String> whitelist = new HashSet<>(Arrays.asList(expectedToAccept));
+ final FilenameWhitelistFilter testFilter = new FilenameWhitelistFilter(whitelist);
+ for (final String str : expectedToAccept) {
+ assertTrue("Filename, " + str + ", in whitelist is accepted", testFilter.accept(testFile, str));
+ }
+
+ final String[] notExpectedToAccept = new String[] { "not-in-whitelist", "meh", "whatever" };
+ for (final String str : notExpectedToAccept) {
+ assertFalse("Filename, " + str + ", not in whitelist is not accepted", testFilter.accept(testFile, str));
+ }
+ }
+
+ @Test
+ public void testFilenameRegexFilter() {
+ final Pattern pattern = Pattern.compile("[a-z]{1,6}");
+ final FilenameRegexFilter testFilter = new FilenameRegexFilter(pattern);
+ final String[] expectedToAccept = new String[] { "duckie", "goes", "quack" };
+ for (final String str : expectedToAccept) {
+ assertTrue("Filename, " + str + ", matching regex expected to accept", testFilter.accept(testFile, str));
+ }
+
+ final String[] notExpectedToAccept = new String[] { "DUCKIE", "1337", "2fast" };
+ for (final String str : notExpectedToAccept) {
+ assertFalse("Filename, " + str + ", not matching regex not expected to accept", testFilter.accept(testFile, str));
+ }
+ }
+
+ @Test
+ public void testFileLastModifiedComparator() {
+ final FileLastModifiedComparator testComparator = new FileLastModifiedComparator();
+ final File oldFile = mock(File.class);
+ final File newFile = mock(File.class);
+ final File equallyNewFile = mock(File.class);
+ when(oldFile.lastModified()).thenReturn(10L);
+ when(newFile.lastModified()).thenReturn(100L);
+ when(equallyNewFile.lastModified()).thenReturn(100L);
+
+ assertTrue("Old file is less than new file", testComparator.compare(oldFile, newFile) < 0);
+ assertTrue("New file is greater than old file", testComparator.compare(newFile, oldFile) > 0);
+ assertTrue("New files are equal", testComparator.compare(newFile, equallyNewFile) == 0);
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestIntentUtils.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestIntentUtils.java
new file mode 100644
index 000000000..186821451
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestIntentUtils.java
@@ -0,0 +1,73 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.util;
+
+import android.content.Intent;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.mozglue.SafeIntent;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.*;
+
+/**
+ * Tests for the Intent utilities.
+ */
+@RunWith(TestRunner.class)
+public class TestIntentUtils {
+
+ private static final Map<String, String> TEST_ENV_VAR_MAP;
+ static {
+ final HashMap<String, String> tempMap = new HashMap<>();
+ tempMap.put("ZERO", "0");
+ tempMap.put("ONE", "1");
+ tempMap.put("STRING", "TEXT");
+ tempMap.put("L_WHITESPACE", " LEFT");
+ tempMap.put("R_WHITESPACE", "RIGHT ");
+ tempMap.put("ALL_WHITESPACE", " ALL ");
+ tempMap.put("WHITESPACE_IN_VALUE", "IN THE MIDDLE");
+ tempMap.put("WHITESPACE IN KEY", "IS_PROBABLY_NOT_VALID_ANYWAY");
+ tempMap.put("BLANK_VAL", "");
+ TEST_ENV_VAR_MAP = Collections.unmodifiableMap(tempMap);
+ }
+
+ private Intent testIntent;
+
+ @Before
+ public void setUp() throws Exception {
+ testIntent = getIntentWithTestData();
+ }
+
+ private static Intent getIntentWithTestData() {
+ final Intent out = new Intent(Intent.ACTION_VIEW);
+ int i = 0;
+ for (final String key : TEST_ENV_VAR_MAP.keySet()) {
+ final String value = key + "=" + TEST_ENV_VAR_MAP.get(key);
+ out.putExtra("env" + i, value);
+ i += 1;
+ }
+ return out;
+ }
+
+ @Test
+ public void testGetEnvVarMap() throws Exception {
+ final HashMap<String, String> actual = IntentUtils.getEnvVarMap(new SafeIntent(testIntent));
+ for (final String actualEnvVarName : actual.keySet()) {
+ assertTrue("Actual key exists in test data: " + actualEnvVarName,
+ TEST_ENV_VAR_MAP.containsKey(actualEnvVarName));
+
+ final String expectedValue = TEST_ENV_VAR_MAP.get(actualEnvVarName);
+ final String actualValue = actual.get(actualEnvVarName);
+ assertEquals("Actual env var value matches test data", expectedValue, actualValue);
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestStringUtils.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestStringUtils.java
new file mode 100644
index 000000000..ee0a705c7
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestStringUtils.java
@@ -0,0 +1,122 @@
+/* -*- 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.util;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class TestStringUtils {
+ @Test
+ public void testIsHttpOrHttps() {
+ // No value
+ assertFalse(StringUtils.isHttpOrHttps(null));
+ assertFalse(StringUtils.isHttpOrHttps(""));
+
+ // Garbage
+ assertFalse(StringUtils.isHttpOrHttps("lksdjflasuf"));
+
+ // URLs with http/https
+ assertTrue(StringUtils.isHttpOrHttps("https://www.google.com"));
+ assertTrue(StringUtils.isHttpOrHttps("http://www.facebook.com"));
+ assertTrue(StringUtils.isHttpOrHttps("https://mozilla.org/en-US/firefox/products/"));
+
+ // IP addresses
+ assertTrue(StringUtils.isHttpOrHttps("https://192.168.0.1"));
+ assertTrue(StringUtils.isHttpOrHttps("http://63.245.215.20/en-US/firefox/products"));
+
+ // Other protocols
+ assertFalse(StringUtils.isHttpOrHttps("ftp://people.mozilla.org"));
+ assertFalse(StringUtils.isHttpOrHttps("javascript:window.google.com"));
+ assertFalse(StringUtils.isHttpOrHttps("tel://1234567890"));
+
+ // No scheme
+ assertFalse(StringUtils.isHttpOrHttps("google.com"));
+ assertFalse(StringUtils.isHttpOrHttps("git@github.com:mozilla/gecko-dev.git"));
+ }
+
+ @Test
+ public void testStripRef() {
+ assertEquals(StringUtils.stripRef(null), null);
+ assertEquals(StringUtils.stripRef(""), "");
+
+ assertEquals(StringUtils.stripRef("??AAABBBCCC"), "??AAABBBCCC");
+ assertEquals(StringUtils.stripRef("https://mozilla.org"), "https://mozilla.org");
+ assertEquals(StringUtils.stripRef("https://mozilla.org#BBBB"), "https://mozilla.org");
+ assertEquals(StringUtils.stripRef("https://mozilla.org/#BBBB"), "https://mozilla.org/");
+ }
+
+ @Test
+ public void testStripScheme() {
+ assertEquals("mozilla.org", StringUtils.stripScheme("http://mozilla.org"));
+ assertEquals("mozilla.org", StringUtils.stripScheme("http://mozilla.org/"));
+ assertEquals("https://mozilla.org", StringUtils.stripScheme("https://mozilla.org"));
+ assertEquals("https://mozilla.org", StringUtils.stripScheme("https://mozilla.org/"));
+ assertEquals("mozilla.org", StringUtils.stripScheme("https://mozilla.org/", StringUtils.UrlFlags.STRIP_HTTPS));
+ assertEquals("mozilla.org", StringUtils.stripScheme("https://mozilla.org", StringUtils.UrlFlags.STRIP_HTTPS));
+ assertEquals("", StringUtils.stripScheme("http://"));
+ assertEquals("", StringUtils.stripScheme("https://", StringUtils.UrlFlags.STRIP_HTTPS));
+ // This edge case is not handled properly yet
+// assertEquals(StringUtils.stripScheme("https://"), "");
+ assertEquals(null, StringUtils.stripScheme(null));
+ }
+
+ @Test
+ public void testIsRTL() {
+ assertFalse(StringUtils.isRTL("mozilla.org"));
+ assertFalse(StringUtils.isRTL("something.عربي"));
+
+ assertTrue(StringUtils.isRTL("عربي"));
+ assertTrue(StringUtils.isRTL("عربي.org"));
+
+ // Text with LTR mark
+ assertFalse(StringUtils.isRTL("\u200EHello"));
+ assertFalse(StringUtils.isRTL("\u200Eعربي"));
+ }
+
+ @Test
+ public void testForceLTR() {
+ assertFalse(StringUtils.isRTL(StringUtils.forceLTR("عربي")));
+ assertFalse(StringUtils.isRTL(StringUtils.forceLTR("عربي.org")));
+
+ // Strings that are already LTR are not modified
+ final String someLtrString = "HelloWorld";
+ assertEquals(someLtrString, StringUtils.forceLTR(someLtrString));
+
+ // We add the LTR mark only once
+ final String someRtlString = "عربي";
+ assertEquals(4, someRtlString.length());
+ final String forcedLtrString = StringUtils.forceLTR(someRtlString);
+ assertEquals(5, forcedLtrString.length());
+ final String forcedAgainLtrString = StringUtils.forceLTR(forcedLtrString);
+ assertEquals(5, forcedAgainLtrString.length());
+ }
+
+ @Test
+ public void testJoin() {
+ assertEquals("", StringUtils.join("", Collections.<String>emptyList()));
+ assertEquals("", StringUtils.join("-", Collections.<String>emptyList()));
+ assertEquals("", StringUtils.join("", Collections.singletonList("")));
+ assertEquals("", StringUtils.join(".", Collections.singletonList("")));
+
+ assertEquals("192.168.0.1", StringUtils.join(".", Arrays.asList("192", "168", "0", "1")));
+ assertEquals("www.mozilla.org", StringUtils.join(".", Arrays.asList("www", "mozilla", "org")));
+
+ assertEquals("hello", StringUtils.join("", Collections.singletonList("hello")));
+ assertEquals("helloworld", StringUtils.join("", Arrays.asList("hello", "world")));
+ assertEquals("hello world", StringUtils.join(" ", Arrays.asList("hello", "world")));
+
+ assertEquals("m::o::z::i::l::l::a", StringUtils.join("::", Arrays.asList("m", "o", "z", "i", "l", "l", "a")));
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestUUIDUtil.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestUUIDUtil.java
new file mode 100644
index 000000000..732dd21b9
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/TestUUIDUtil.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.util;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import static org.junit.Assert.*;
+
+/**
+ * Tests for uuid utils.
+ */
+@RunWith(TestRunner.class)
+public class TestUUIDUtil {
+ private static final String[] validUUIDs = {
+ "904cd9f8-af63-4525-8ce0-b9127e5364fa",
+ "8d584bd2-00ea-4043-a617-ed4ce7018ed0",
+ "3abad327-2669-4f68-b9ef-7ace8c5314d6",
+ };
+
+ private static final String[] invalidUUIDs = {
+ "its-not-a-uuid-mate",
+ "904cd9f8-af63-4525-8ce0-b9127e5364falol",
+ "904cd9f8-af63-4525-8ce0-b9127e5364f",
+ };
+
+ @Test
+ public void testUUIDRegex() {
+ for (final String uuid : validUUIDs) {
+ assertTrue("Valid UUID matches UUID-regex", uuid.matches(UUIDUtil.UUID_REGEX));
+ }
+ for (final String uuid : invalidUUIDs) {
+ assertFalse("Invalid UUID does not match UUID-regex", uuid.matches(UUIDUtil.UUID_REGEX));
+ }
+ }
+
+ @Test
+ public void testUUIDPattern() {
+ for (final String uuid : validUUIDs) {
+ assertTrue("Valid UUID matches UUID-regex", UUIDUtil.UUID_PATTERN.matcher(uuid).matches());
+ }
+ for (final String uuid : invalidUUIDs) {
+ assertFalse("Invalid UUID does not match UUID-regex", UUIDUtil.UUID_PATTERN.matcher(uuid).matches());
+ }
+ }
+}
diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/publicsuffix/TestPublicSuffix.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/publicsuffix/TestPublicSuffix.java
new file mode 100644
index 000000000..e47d361c0
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/util/publicsuffix/TestPublicSuffix.java
@@ -0,0 +1,62 @@
+package org.mozilla.gecko.util.publicsuffix;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(TestRunner.class)
+public class TestPublicSuffix {
+ @Test
+ public void testStripPublicSuffix() {
+ // Test empty value
+ Assert.assertEquals("",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, ""));
+
+ // Test domains with public suffix
+ Assert.assertEquals("www.mozilla",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "www.mozilla.org"));
+ Assert.assertEquals("www.google",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "www.google.com"));
+ Assert.assertEquals("foobar",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "foobar.blogspot.com"));
+ Assert.assertEquals("independent",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "independent.co.uk"));
+ Assert.assertEquals("biz",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "biz.com.ua"));
+ Assert.assertEquals("example",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "example.org"));
+ Assert.assertEquals("example",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "example.pvt.k12.ma.us"));
+
+ // Test domain without public suffix
+ Assert.assertEquals("localhost",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "localhost"));
+ Assert.assertEquals("firefox.mozilla",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "firefox.mozilla"));
+
+ // IDN domains
+ Assert.assertEquals("ουτοπία.δπθ",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "ουτοπία.δπθ.gr"));
+ Assert.assertEquals("a网络A",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "a网络A.网络.Cn"));
+
+ // Other non-domain values
+ Assert.assertEquals("192.168.0.1",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "192.168.0.1"));
+ Assert.assertEquals("asdflkj9uahsd",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "asdflkj9uahsd"));
+
+ // Other trailing and other types of dots
+ Assert.assertEquals("www.mozilla。home.example",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "www.mozilla。home.example。org"));
+ Assert.assertEquals("example",
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, "example.org"));
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testStripPublicSuffixThrowsException() {
+ PublicSuffix.stripPublicSuffix(RuntimeEnvironment.application, null);
+ }
+}