diff --git a/b2g/config/emulator-ics/sources.xml b/b2g/config/emulator-ics/sources.xml
index fe6c7c4e77b..c92156a382a 100644
--- a/b2g/config/emulator-ics/sources.xml
+++ b/b2g/config/emulator-ics/sources.xml
@@ -19,11 +19,11 @@
Context.getPackageResourcePath
to find an implicit
* package path. Reuses the existing Distribution if one exists.
*/
+ @RobocopTarget
public static void init(final Context context) {
Distribution.init(Distribution.getInstance(context));
}
@@ -166,6 +236,17 @@ public final class Distribution {
this(context, context.getPackageResourcePath(), null);
}
+ /**
+ * This method is called by ReferrerReceiver when we receive a post-install
+ * notification from Google Play.
+ *
+ * @param ref a parsed referrer value from the store-supplied intent.
+ */
+ public static void onReceivedReferrer(ReferrerDescriptor ref) {
+ // Track the referrer object for distribution handling.
+ referrer = ref;
+ }
+
/**
* Helper to grab a file in the distribution directory.
*
@@ -214,9 +295,11 @@ public final class Distribution {
} catch (IOException e) {
Log.e(LOGTAG, "Error getting distribution descriptor file.", e);
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION);
return null;
} catch (JSONException e) {
Log.e(LOGTAG, "Error parsing preferences.json", e);
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION);
return null;
}
}
@@ -232,11 +315,13 @@ public final class Distribution {
return new JSONArray(getFileContents(bookmarks));
} catch (IOException e) {
Log.e(LOGTAG, "Error getting bookmarks", e);
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION);
+ return null;
} catch (JSONException e) {
Log.e(LOGTAG, "Error parsing bookmarks.json", e);
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION);
+ return null;
}
-
- return null;
}
/**
@@ -245,9 +330,12 @@ public final class Distribution {
* Postcondition: if this returns true, distributionDir will have been
* set and populated.
*
+ * This method is *only* protected for use from testDistribution.
+ *
* @return true if we've set a distribution.
*/
- private boolean doInit() {
+ @RobocopTarget
+ protected boolean doInit() {
ThreadUtils.assertNotOnUiThread();
// Bail if we've already tried to initialize the distribution, and
@@ -274,8 +362,9 @@ public final class Distribution {
return true;
}
- // We try the APK, then the system directory.
+ // We try the install intent, then the APK, then the system directory.
final boolean distributionSet =
+ checkIntentDistribution() ||
checkAPKDistribution() ||
checkSystemDistribution();
@@ -286,6 +375,153 @@ public final class Distribution {
return distributionSet;
}
+ /**
+ * If applicable, download and select the distribution specified in
+ * the referrer intent.
+ *
+ * @return true if a referrer-supplied distribution was selected.
+ */
+ private boolean checkIntentDistribution() {
+ if (referrer == null) {
+ return false;
+ }
+
+ URI uri = getReferredDistribution(referrer);
+ if (uri == null) {
+ return false;
+ }
+
+ long start = SystemClock.uptimeMillis();
+ Log.v(LOGTAG, "Downloading referred distribution: " + uri);
+
+ try {
+ HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
+
+ connection.setRequestProperty(HTTP.USER_AGENT, GeckoAppShell.getGeckoInterface().getDefaultUAString());
+ connection.setRequestProperty("Accept", EXPECTED_CONTENT_TYPE);
+
+ try {
+ final JarInputStream distro;
+ try {
+ distro = fetchDistribution(uri, connection);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Error fetching distribution from network.", e);
+ recordFetchTelemetry(e);
+ return false;
+ }
+
+ long end = SystemClock.uptimeMillis();
+ final long duration = end - start;
+ Log.d(LOGTAG, "Distro fetch took " + duration + "ms; result? " + (distro != null));
+ Telemetry.HistogramAdd(HISTOGRAM_DOWNLOAD_TIME_MS, clamp(MAX_DOWNLOAD_TIME_MSEC, duration));
+
+ if (distro == null) {
+ // Nothing to do.
+ return false;
+ }
+
+ // Try to copy distribution files from the fetched stream.
+ try {
+ Log.d(LOGTAG, "Copying files from fetched zip.");
+ if (copyFilesFromStream(distro)) {
+ // We always copy to the data dir, and we only copy files from
+ // a 'distribution' subdirectory. Track our dist dir now that
+ // we know it.
+ this.distributionDir = new File(getDataDir(), DISTRIBUTION_PATH);
+ return true;
+ }
+ } catch (SecurityException e) {
+ Log.e(LOGTAG, "Security exception copying files. Corrupt or malicious?", e);
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_POST_FETCH_SECURITY_EXCEPTION);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Error copying files from distribution.", e);
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_POST_FETCH_EXCEPTION);
+ } finally {
+ distro.close();
+ }
+ } finally {
+ connection.disconnect();
+ }
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error copying distribution files from network.", e);
+ recordFetchTelemetry(e);
+ }
+
+ return false;
+ }
+
+ private static final int clamp(long v, long c) {
+ return (int) Math.min(c, v);
+ }
+
+ /**
+ * Fetch the provided URI, returning a {@link JarInputStream} if the response body
+ * is appropriate.
+ *
+ * Protected to allow for mocking.
+ *
+ * @return the entity body as a stream, or null on failure.
+ */
+ @SuppressWarnings("static-method")
+ @RobocopTarget
+ protected JarInputStream fetchDistribution(URI uri, HttpURLConnection connection) throws IOException {
+ final int status = connection.getResponseCode();
+
+ Log.d(LOGTAG, "Distribution fetch: " + status);
+ // We record HTTP statuses as 2xx, 3xx, 4xx, 5xx => 2, 3, 4, 5.
+ final int value;
+ if (status > 599 || status < 100) {
+ Log.wtf(LOGTAG, "Unexpected HTTP status code: " + status);
+ value = CODE_CATEGORY_STATUS_OUT_OF_RANGE;
+ } else {
+ value = status / 100;
+ }
+
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, value);
+
+ if (status != 200) {
+ Log.w(LOGTAG, "Got status " + status + " fetching distribution.");
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_NON_SUCCESS_RESPONSE);
+ return null;
+ }
+
+ final String contentType = connection.getContentType();
+ if (contentType == null || !contentType.startsWith(EXPECTED_CONTENT_TYPE)) {
+ Log.w(LOGTAG, "Malformed response: invalid Content-Type.");
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_INVALID_CONTENT_TYPE);
+ return null;
+ }
+
+ return new JarInputStream(new BufferedInputStream(connection.getInputStream()), true);
+ }
+
+ private static void recordFetchTelemetry(final Exception exception) {
+ if (exception == null) {
+ // Should never happen.
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_EXCEPTION);
+ return;
+ }
+
+ if (exception instanceof UnknownHostException) {
+ // Unknown host => we're offline.
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_OFFLINE);
+ return;
+ }
+
+ if (exception instanceof SSLException) {
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_SSL_ERROR);
+ return;
+ }
+
+ if (exception instanceof ProtocolException ||
+ exception instanceof SocketException) {
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_SOCKET_ERROR);
+ return;
+ }
+
+ Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_EXCEPTION);
+ }
+
/**
* Execute tasks that wanted to run when we were done loading
* the distribution. These tasks are expected to call {@link #exists()}
@@ -308,7 +544,7 @@ public final class Distribution {
// We always copy to the data dir, and we only copy files from
// a 'distribution' subdirectory. Track our dist dir now that
// we know it.
- this.distributionDir = new File(getDataDir(), "distribution/");
+ this.distributionDir = new File(getDataDir(), DISTRIBUTION_PATH);
return true;
}
} catch (IOException e) {
@@ -330,6 +566,41 @@ public final class Distribution {
return false;
}
+ /**
+ * Unpack distribution files from a downloaded jar stream.
+ *
+ * The caller is responsible for closing the provided stream.
+ */
+ private boolean copyFilesFromStream(JarInputStream jar) throws FileNotFoundException, IOException {
+ final byte[] buffer = new byte[1024];
+ boolean distributionSet = false;
+ JarEntry entry;
+ while ((entry = jar.getNextJarEntry()) != null) {
+ final String name = entry.getName();
+
+ if (entry.isDirectory()) {
+ // We'll let getDataFile deal with creating the directory hierarchy.
+ // Yes, we can do better, but it can wait.
+ continue;
+ }
+
+ if (!name.startsWith(DISTRIBUTION_PATH)) {
+ continue;
+ }
+
+ File outFile = getDataFile(name);
+ if (outFile == null) {
+ continue;
+ }
+
+ distributionSet = true;
+
+ writeStream(jar, outFile, entry.getTime(), buffer);
+ }
+
+ return distributionSet;
+ }
+
/**
* Copies the /distribution folder out of the APK and into the app's data directory.
* Returns true if distribution files were found and copied.
@@ -352,7 +623,7 @@ public final class Distribution {
continue;
}
- if (!name.startsWith("distribution/")) {
+ if (!name.startsWith(DISTRIBUTION_PATH)) {
continue;
}
@@ -413,6 +684,29 @@ public final class Distribution {
return outFile;
}
+ private URI getReferredDistribution(ReferrerDescriptor descriptor) {
+ final String content = descriptor.content;
+ if (content == null) {
+ return null;
+ }
+
+ // We restrict here to avoid injection attacks. After all,
+ // we're downloading a distribution payload based on intent input.
+ if (!content.matches("^[a-zA-Z0-9]+$")) {
+ Log.e(LOGTAG, "Invalid referrer content: " + content);
+ Telemetry.HistogramAdd(HISTOGRAM_REFERRER_INVALID, 1);
+ return null;
+ }
+
+ try {
+ return new URI(FETCH_PROTOCOL, FETCH_HOSTNAME, FETCH_PATH + content + FETCH_EXTENSION, null);
+ } catch (URISyntaxException e) {
+ // This should never occur.
+ Log.wtf(LOGTAG, "Invalid URI with content " + content + "!");
+ return null;
+ }
+ }
+
/**
* After calling this method, either distributionDir
* will be set, or there is no distribution in use.
@@ -432,7 +726,7 @@ public final class Distribution {
// the APK, or it exists in /system/.
// Look in each location in turn.
// (This could be optimized by caching the path in shared prefs.)
- File copied = new File(getDataDir(), "distribution/");
+ File copied = new File(getDataDir(), DISTRIBUTION_PATH);
if (copied.exists()) {
return this.distributionDir = copied;
}
diff --git a/mobile/android/base/distribution/ReferrerDescriptor.java b/mobile/android/base/distribution/ReferrerDescriptor.java
new file mode 100644
index 00000000000..f422810ed14
--- /dev/null
+++ b/mobile/android/base/distribution/ReferrerDescriptor.java
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.distribution;
+
+import org.mozilla.gecko.mozglue.RobocopTarget;
+
+import android.net.Uri;
+
+/**
+ * Encapsulates access to values encoded in the "referrer" extra of an install intent.
+ *
+ * This object is immutable.
+ *
+ * Example input:
+ *
+ * "utm_source=campsource&utm_medium=campmed&utm_term=term%2Bhere&utm_content=content&utm_campaign=name"
+ */
+@RobocopTarget
+public class ReferrerDescriptor {
+ public final String source;
+ public final String medium;
+ public final String term;
+ public final String content;
+ public final String campaign;
+
+ public ReferrerDescriptor(final String referrer) {
+ if (referrer == null) {
+ source = null;
+ medium = null;
+ term = null;
+ content = null;
+ campaign = null;
+ return;
+ }
+
+ final Uri u = new Uri.Builder()
+ .scheme("http")
+ .authority("local")
+ .path("/")
+ .encodedQuery(referrer).build();
+
+ source = u.getQueryParameter("utm_source");
+ medium = u.getQueryParameter("utm_medium");
+ term = u.getQueryParameter("utm_term");
+ content = u.getQueryParameter("utm_content");
+ campaign = u.getQueryParameter("utm_campaign");
+ }
+
+ @Override
+ public String toString() {
+ return "{s: " + source + ", m: " + medium + ", t: " + term + ", c: " + content + ", c: " + campaign + "}";
+ }
+}
diff --git a/mobile/android/base/distribution/ReferrerReceiver.java b/mobile/android/base/distribution/ReferrerReceiver.java
new file mode 100644
index 00000000000..9b310a08158
--- /dev/null
+++ b/mobile/android/base/distribution/ReferrerReceiver.java
@@ -0,0 +1,76 @@
+/* -*- 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.distribution;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.text.TextUtils;
+import android.util.Log;
+
+public class ReferrerReceiver extends BroadcastReceiver {
+ private static final String LOGTAG = "GeckoReferrerReceiver";
+
+ private static final String ACTION_INSTALL_REFERRER = "com.android.vending.INSTALL_REFERRER";
+
+ /**
+ * If the install intent has this source, we'll track the campaign ID.
+ */
+ private static final String MOZILLA_UTM_SOURCE = "mozilla";
+
+ /**
+ * If the install intent has this campaign, we'll load the specified distribution.
+ */
+ private static final String DISTRIBUTION_UTM_CAMPAIGN = "distribution";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Log.v(LOGTAG, "Received intent " + intent);
+ if (!ACTION_INSTALL_REFERRER.equals(intent.getAction())) {
+ // This should never happen.
+ return;
+ }
+
+ ReferrerDescriptor referrer = new ReferrerDescriptor(intent.getStringExtra("referrer"));
+
+ // Track the referrer object for distribution handling.
+ if (TextUtils.equals(referrer.campaign, DISTRIBUTION_UTM_CAMPAIGN)) {
+ Distribution.onReceivedReferrer(referrer);
+ } else {
+ Log.d(LOGTAG, "Not downloading distribution: non-matching campaign.");
+ }
+
+ // If this is a Mozilla campaign, pass the campaign along to Gecko.
+ if (TextUtils.equals(referrer.source, MOZILLA_UTM_SOURCE)) {
+ propagateMozillaCampaign(referrer);
+ }
+ }
+
+
+ private void propagateMozillaCampaign(ReferrerDescriptor referrer) {
+ if (referrer.campaign == null) {
+ return;
+ }
+
+ try {
+ final JSONObject data = new JSONObject();
+ data.put("id", "playstore");
+ data.put("version", referrer.campaign);
+ String payload = data.toString();
+
+ // Try to make sure the prefs are written as a group.
+ final GeckoEvent event = GeckoEvent.createBroadcastEvent("Campaign:Set", payload);
+ GeckoAppShell.sendEventToGecko(event);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error propagating campaign identifier.", e);
+ }
+ }
+}
diff --git a/mobile/android/base/moz.build b/mobile/android/base/moz.build
index 13ea43e4563..f8404c08c8d 100644
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -156,6 +156,8 @@ gbjar.sources += [
'db/TabsProvider.java',
'db/TopSitesCursorWrapper.java',
'distribution/Distribution.java',
+ 'distribution/ReferrerDescriptor.java',
+ 'distribution/ReferrerReceiver.java',
'DoorHangerPopup.java',
'DynamicToolbar.java',
'EditBookmarkDialog.java',
@@ -354,7 +356,6 @@ gbjar.sources += [
'prompts/PromptService.java',
'prompts/TabInput.java',
'ReaderModeUtils.java',
- 'ReferrerReceiver.java',
'Restarter.java',
'ScrollAnimator.java',
'ServiceNotificationClient.java',
diff --git a/mobile/android/base/tests/testDistribution.java b/mobile/android/base/tests/testDistribution.java
index 7d8b7edc2e3..50a75e8191a 100644
--- a/mobile/android/base/tests/testDistribution.java
+++ b/mobile/android/base/tests/testDistribution.java
@@ -2,19 +2,29 @@ package org.mozilla.gecko.tests;
import java.io.File;
import java.io.FileOutputStream;
+import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.util.jar.JarInputStream;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.Actions;
+import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.distribution.Distribution;
+import org.mozilla.gecko.distribution.ReferrerDescriptor;
+import org.mozilla.gecko.mozglue.RobocopTarget;
import org.mozilla.gecko.util.ThreadUtils;
import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
import android.content.SharedPreferences;
+import android.util.Log;
/**
* Tests distribution customization.
@@ -28,6 +38,38 @@ import android.content.SharedPreferences;
* engine.xml
*/
public class testDistribution extends ContentProviderTest {
+ private static final String CLASS_REFERRER_RECEIVER = "org.mozilla.gecko.distribution.ReferrerReceiver";
+ private static final String ACTION_INSTALL_REFERRER = "com.android.vending.INSTALL_REFERRER";
+ private static final int WAIT_TIMEOUT_MSEC = 10000;
+ public static final String LOGTAG = "GeckoTestDistribution";
+
+ public static class TestableDistribution extends Distribution {
+ @Override
+ protected JarInputStream fetchDistribution(URI uri,
+ HttpURLConnection connection) throws IOException {
+ Log.i(LOGTAG, "Not downloading: this is a test.");
+ return null;
+ }
+
+ public TestableDistribution(Context context) {
+ super(context);
+ }
+
+ public void go() {
+ doInit();
+ }
+
+ @RobocopTarget
+ public static void clearReferrerDescriptorForTesting() {
+ referrer = null;
+ }
+
+ @RobocopTarget
+ public static ReferrerDescriptor getReferrerDescriptorForTesting() {
+ return referrer;
+ }
+ }
+
private static final String MOCK_PACKAGE = "mock-package.zip";
private static final int PREF_REQUEST_ID = 0x7357;
@@ -65,7 +107,7 @@ public class testDistribution extends ContentProviderTest {
mAsserter.dumpLog("Background task completed. Proceeding.");
}
- public void testDistribution() {
+ public void testDistribution() throws Exception {
mActivity = getActivity();
String mockPackagePath = getMockPackagePath();
@@ -87,6 +129,90 @@ public class testDistribution extends ContentProviderTest {
setTestLocale("es-MX");
initDistribution(mockPackagePath);
checkLocalizedPreferences("es-MX");
+
+ // Test the (stubbed) download interaction.
+ setTestLocale("en-US");
+ clearDistributionPref();
+ doTestValidReferrerIntent();
+
+ clearDistributionPref();
+ doTestInvalidReferrerIntent();
+ }
+
+ public void doTestValidReferrerIntent() throws Exception {
+ // Send the faux-download intent.
+ // Equivalent to
+ // am broadcast -a com.android.vending.INSTALL_REFERRER \
+ // -n org.mozilla.fennec/org.mozilla.gecko.distribution.ReferrerReceiver \
+ // --es "referrer" "utm_source=mozilla&utm_medium=testmedium&utm_term=testterm&utm_content=testcontent&utm_campaign=distribution"
+ final String ref = "utm_source=mozilla&utm_medium=testmedium&utm_term=testterm&utm_content=testcontent&utm_campaign=distribution";
+ final Intent intent = new Intent(ACTION_INSTALL_REFERRER);
+ intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, CLASS_REFERRER_RECEIVER);
+ intent.putExtra("referrer", ref);
+ mActivity.sendBroadcast(intent);
+
+ // Wait for the intent to be processed.
+ final TestableDistribution distribution = new TestableDistribution(mActivity);
+
+ final Object wait = new Object();
+ distribution.addOnDistributionReadyCallback(new Runnable() {
+ @Override
+ public void run() {
+ mAsserter.ok(!distribution.exists(), "Not processed.", "No download because we're offline.");
+ ReferrerDescriptor referrerValue = TestableDistribution.getReferrerDescriptorForTesting();
+ mAsserter.dumpLog("Referrer was " + referrerValue);
+ mAsserter.is(referrerValue.content, "testcontent", "Referrer content");
+ mAsserter.is(referrerValue.medium, "testmedium", "Referrer medium");
+ mAsserter.is(referrerValue.campaign, "distribution", "Referrer campaign");
+ synchronized (wait) {
+ wait.notifyAll();
+ }
+ }
+ });
+
+ distribution.go();
+ synchronized (wait) {
+ wait.wait(WAIT_TIMEOUT_MSEC);
+ }
+ }
+
+ /**
+ * Test processing if the campaign isn't "distribution". The intent shouldn't
+ * result in a download, and won't be saved as the temporary referrer,
+ * even if we *do* include it in a Campaign:Set message.
+ */
+ public void doTestInvalidReferrerIntent() throws Exception {
+ // Send the faux-download intent.
+ // Equivalent to
+ // am broadcast -a com.android.vending.INSTALL_REFERRER \
+ // -n org.mozilla.fennec/org.mozilla.gecko.distribution.ReferrerReceiver \
+ // --es "referrer" "utm_source=mozilla&utm_medium=testmedium&utm_term=testterm&utm_content=testcontent&utm_campaign=testname"
+ final String ref = "utm_source=mozilla&utm_medium=testmedium&utm_term=testterm&utm_content=testcontent&utm_campaign=testname";
+ final Intent intent = new Intent(ACTION_INSTALL_REFERRER);
+ intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, CLASS_REFERRER_RECEIVER);
+ intent.putExtra("referrer", ref);
+ mActivity.sendBroadcast(intent);
+
+ // Wait for the intent to be processed.
+ final TestableDistribution distribution = new TestableDistribution(mActivity);
+
+ final Object wait = new Object();
+ distribution.addOnDistributionReadyCallback(new Runnable() {
+ @Override
+ public void run() {
+ mAsserter.ok(!distribution.exists(), "Not processed.", "No download because campaign was wrong.");
+ ReferrerDescriptor referrerValue = TestableDistribution.getReferrerDescriptorForTesting();
+ mAsserter.is(referrerValue, null, "No referrer.");
+ synchronized (wait) {
+ wait.notifyAll();
+ }
+ }
+ });
+
+ distribution.go();
+ synchronized (wait) {
+ wait.wait(WAIT_TIMEOUT_MSEC);
+ }
}
// Initialize the distribution from the mock package.
@@ -288,12 +414,16 @@ public class testDistribution extends ContentProviderTest {
return mockPackagePath;
}
- // Clears the distribution pref to return distribution state to STATE_UNKNOWN
+ /**
+ * Clears the distribution pref to return distribution state to STATE_UNKNOWN,
+ * and wipes the in-memory referrer pigeonhole.
+ */
private void clearDistributionPref() {
mAsserter.dumpLog("Clearing distribution pref.");
SharedPreferences settings = mActivity.getSharedPreferences("GeckoApp", Activity.MODE_PRIVATE);
String keyName = mActivity.getPackageName() + ".distribution_state";
settings.edit().remove(keyName).commit();
+ TestableDistribution.clearReferrerDescriptorForTesting();
}
@Override
diff --git a/mobile/android/tests/browser/junit3/moz.build b/mobile/android/tests/browser/junit3/moz.build
index 7d81516888d..c620954755e 100644
--- a/mobile/android/tests/browser/junit3/moz.build
+++ b/mobile/android/tests/browser/junit3/moz.build
@@ -11,6 +11,7 @@ jar.sources += [
'src/harness/BrowserInstrumentationTestRunner.java',
'src/harness/BrowserTestListener.java',
'src/tests/BrowserTestCase.java',
+ 'src/tests/TestDistribution.java',
'src/tests/TestGeckoSharedPrefs.java',
'src/tests/TestJarReader.java',
'src/tests/TestRawResource.java',
diff --git a/mobile/android/tests/browser/junit3/src/tests/TestDistribution.java b/mobile/android/tests/browser/junit3/src/tests/TestDistribution.java
new file mode 100644
index 00000000000..dcc2a9fafc7
--- /dev/null
+++ b/mobile/android/tests/browser/junit3/src/tests/TestDistribution.java
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.browser.tests;
+
+import org.mozilla.gecko.distribution.ReferrerDescriptor;
+
+public class TestDistribution extends BrowserTestCase {
+ private static final String TEST_REFERRER_STRING = "utm_source=campsource&utm_medium=campmed&utm_term=term%2Bhere&utm_content=content&utm_campaign=name";
+ private static final String TEST_MALFORMED_REFERRER_STRING = "utm_source=campsource&utm_medium=campmed&utm_term=term%2";
+
+ public void testReferrerParsing() {
+ ReferrerDescriptor good = new ReferrerDescriptor(TEST_REFERRER_STRING);
+ assertEquals("campsource", good.source);
+ assertEquals("campmed", good.medium);
+ assertEquals("term+here", good.term);
+ assertEquals("content", good.content);
+ assertEquals("name", good.campaign);
+
+ // Uri.Builder is permissive.
+ ReferrerDescriptor bad = new ReferrerDescriptor(TEST_MALFORMED_REFERRER_STRING);
+ assertEquals("campsource", bad.source);
+ assertEquals("campmed", bad.medium);
+ assertFalse("term+here".equals(bad.term));
+ assertNull(bad.content);
+ assertNull(bad.campaign);
+
+ ReferrerDescriptor ugly = new ReferrerDescriptor(null);
+ assertNull(ugly.source);
+ assertNull(ugly.medium);
+ assertNull(ugly.term);
+ assertNull(ugly.content);
+ assertNull(ugly.campaign);
+ }
+}
diff --git a/toolkit/components/telemetry/Histograms.json b/toolkit/components/telemetry/Histograms.json
index 2ab5b57efe7..a0214783eb3 100644
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -2959,6 +2959,28 @@
"extended_statistics_ok": true,
"description": "PLACES: Time to calculate the md5 hash for a backup"
},
+ "FENNEC_DISTRIBUTION_REFERRER_INVALID": {
+ "expires_in_version": "never",
+ "kind": "flag",
+ "description": "Whether the referrer intent specified an invalid distribution name",
+ "cpp_guard": "ANDROID"
+ },
+ "FENNEC_DISTRIBUTION_CODE_CATEGORY": {
+ "expires_in_version": "never",
+ "kind": "enumerated",
+ "n_values": 20,
+ "description": "First digit of HTTP result code, or error category, during distribution download",
+ "cpp_guard": "ANDROID"
+ },
+ "FENNEC_DISTRIBUTION_DOWNLOAD_TIME_MS": {
+ "expires_in_version": "never",
+ "kind": "exponential",
+ "low": 100,
+ "high": "40000",
+ "n_buckets": 30,
+ "description": "Time taken to download a specified distribution file (msec)",
+ "cpp_guard": "ANDROID"
+ },
"FENNEC_FAVICONS_COUNT": {
"expires_in_version": "never",
"kind": "exponential",
diff --git a/toolkit/devtools/discovery/discovery.js b/toolkit/devtools/discovery/discovery.js
index 7e14e9f581c..fe54b81e1ed 100644
--- a/toolkit/devtools/discovery/discovery.js
+++ b/toolkit/devtools/discovery/discovery.js
@@ -39,10 +39,9 @@ const UDPSocket = CC("@mozilla.org/network/udp-socket;1",
"nsIUDPSocket",
"init");
-// TODO Bug 1027456: May need to reserve these with IANA
const SCAN_PORT = 50624;
const UPDATE_PORT = 50625;
-const ADDRESS = "224.0.0.200";
+const ADDRESS = "224.0.0.115";
const REPLY_TIMEOUT = 5000;
const { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {});
@@ -158,6 +157,8 @@ function Discovery() {
this._onRemoteUpdate = this._onRemoteUpdate.bind(this);
this._purgeMissingDevices = this._purgeMissingDevices.bind(this);
+ Services.obs.addObserver(this, "network-active-changed", false);
+
this._getSystemInfo();
}
@@ -295,6 +296,35 @@ Discovery.prototype = {
this._transports.update = null;
},
+ observe: function(subject, topic, data) {
+ if (topic !== "network-active-changed") {
+ return;
+ }
+ let activeNetwork = subject;
+ if (!activeNetwork) {
+ log("No active network");
+ return;
+ }
+ activeNetwork = activeNetwork.QueryInterface(Ci.nsINetworkInterface);
+ log("Active network changed to: " + activeNetwork.type);
+ // UDP sockets go down when the device goes offline, so we'll restart them
+ // when the active network goes back to WiFi.
+ if (activeNetwork.type === Ci.nsINetworkInterface.NETWORK_TYPE_WIFI) {
+ this._restartListening();
+ }
+ },
+
+ _restartListening: function() {
+ if (this._transports.scan) {
+ this._stopListeningForScan();
+ this._startListeningForScan();
+ }
+ if (this._transports.update) {
+ this._stopListeningForUpdate();
+ this._startListeningForUpdate();
+ }
+ },
+
/**
* When sending message, we can use either transport, so just pick the first
* one currently alive.
diff --git a/toolkit/devtools/server/actors/webaudio.js b/toolkit/devtools/server/actors/webaudio.js
index 6472093f185..4cea0f9e925 100644
--- a/toolkit/devtools/server/actors/webaudio.js
+++ b/toolkit/devtools/server/actors/webaudio.js
@@ -363,7 +363,7 @@ let WebAudioActor = exports.WebAudioActor = protocol.ActorClass({
let { caller, args, window, name } = functionCall.details;
let source = caller;
let dest = args[0];
- let isAudioParam = dest instanceof window.AudioParam;
+ let isAudioParam = dest ? getConstructorName(dest) === "AudioParam" : false;
// audionode.connect(param)
if (name === "connect" && isAudioParam) {
@@ -433,8 +433,9 @@ let WebAudioActor = exports.WebAudioActor = protocol.ActorClass({
},
"connect-param": {
type: "connectParam",
- source: Arg(0, "audionode"),
- param: Arg(1, "string")
+ source: Option(0, "audionode"),
+ dest: Option(0, "audionode"),
+ param: Option(0, "string")
},
"change-param": {
type: "changeParam",
@@ -461,12 +462,30 @@ let WebAudioActor = exports.WebAudioActor = protocol.ActorClass({
// Ensure AudioNode is wrapped.
node = new XPCNativeWrapper(node);
+ this._instrumentParams(node);
+
let actor = new AudioNodeActor(this.conn, node);
this.manage(actor);
this._nativeToActorID.set(node.id, actor.actorID);
return actor;
},
+ /**
+ * Takes an XrayWrapper node, and attaches the node's `nativeID`
+ * to the AudioParams as `_parentID`, as well as the the type of param
+ * as a string on `_paramName`.
+ */
+ _instrumentParams: function (node) {
+ let type = getConstructorName(node);
+ Object.keys(NODE_PROPERTIES[type])
+ .filter(isAudioParam.bind(null, node))
+ .forEach(paramName => {
+ let param = node[paramName];
+ param._parentID = node.id;
+ param._paramName = paramName;
+ });
+ },
+
/**
* Takes an AudioNode and returns the stored actor for it.
* In some cases, we won't have an actor stored (for example,
@@ -505,10 +524,15 @@ let WebAudioActor = exports.WebAudioActor = protocol.ActorClass({
/**
* Called when an audio node is connected to an audio param.
- * Implement in bug 986705
*/
- _onConnectParam: function (source, dest) {
- // TODO bug 986705
+ _onConnectParam: function (source, param) {
+ let sourceActor = this._getActorByNativeID(source.id);
+ let destActor = this._getActorByNativeID(param._parentID);
+ emit(this, "connect-param", {
+ source: sourceActor,
+ dest: destActor,
+ param: param._paramName
+ });
},
/**
diff --git a/toolkit/identity/FirefoxAccounts.jsm b/toolkit/identity/FirefoxAccounts.jsm
index 8ba3817f85c..e8374480caa 100644
--- a/toolkit/identity/FirefoxAccounts.jsm
+++ b/toolkit/identity/FirefoxAccounts.jsm
@@ -187,7 +187,7 @@ FxAccountsService.prototype = {
error => {
log.error("get assertion failed: " + JSON.stringify(error));
// Cancellation is passed through an error channel; here we reroute.
- if (error.details && (error.details.error == "DIALOG_CLOSED_BY_USER")) {
+ if (error.error && (error.error.details == "DIALOG_CLOSED_BY_USER")) {
return this.doCancel(aRPId);
}
this.doError(aRPId, error);
diff --git a/toolkit/mozapps/installer/packager.mk b/toolkit/mozapps/installer/packager.mk
index ba1cb54dbb5..8af082a63cd 100644
--- a/toolkit/mozapps/installer/packager.mk
+++ b/toolkit/mozapps/installer/packager.mk
@@ -612,7 +612,6 @@ NO_PKG_FILES += \
res/samples \
res/throbber \
shlibsign* \
- ssltunnel* \
certutil* \
pk12util* \
BadCertServer* \
@@ -629,6 +628,13 @@ NO_PKG_FILES += \
*.dSYM \
$(NULL)
+# If a manifest has not been supplied, the following
+# files should be excluded from the package too
+ifndef MOZ_PKG_MANIFEST
+NO_PKG_FILES += \
+ ssltunnel*
+endif
+
# browser/locales/Makefile uses this makefile for its variable defs, but
# doesn't want the libs:: rule.
ifndef PACKAGER_NO_LIBS