--- a/dom/push/PushService.jsm
+++ b/dom/push/PushService.jsm
@@ -5,29 +5,37 @@
"use strict";
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;
-const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
-Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/AppConstants.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-const {PushServiceWebSocket} = Cu.import("resource://gre/modules/PushServiceWebSocket.jsm");
-const {PushServiceHttp2} = Cu.import("resource://gre/modules/PushServiceHttp2.jsm");
const {PushCrypto} = Cu.import("resource://gre/modules/PushCrypto.jsm");
+const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
-// Currently supported protocols: WebSocket.
-const CONNECTION_PROTOCOLS = [PushServiceWebSocket, PushServiceHttp2];
+XPCOMUtils.defineLazyModuleGetter(this, "PushServiceAndroidGCM",
+ "resource://gre/modules/PushServiceAndroidGCM.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PushServiceHttp2",
+ "resource://gre/modules/PushServiceHttp2.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PushServiceWebSocket",
+ "resource://gre/modules/PushServiceWebSocket.jsm");
+
+// Order matters! First in the list gets the first opportunity to serve.
+const CONNECTION_PROTOCOLS = AppConstants.MOZ_WIDGET_TOOLKIT != 'android'
+ ? [PushServiceWebSocket, PushServiceHttp2]
+ : [PushServiceAndroidGCM];
XPCOMUtils.defineLazyModuleGetter(this, "AlarmService",
"resource://gre/modules/AlarmService.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "gContentSecurityManager",
"@mozilla.org/contentsecuritymanager;1",
"nsIContentSecurityManager");
new file mode 100644
--- /dev/null
+++ b/dom/push/PushServiceAndroidGCM.jsm
@@ -0,0 +1,298 @@
+/* jshint moz: true, esnext: true */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
+const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
+Cu.import("resource://gre/modules/Messaging.jsm"); /*global: Services */
+Cu.import("resource://gre/modules/Services.jsm"); /*global: Services */
+Cu.import("resource://gre/modules/Preferences.jsm"); /*global: Preferences */
+Cu.import("resource://gre/modules/Promise.jsm"); /*global: Promise */
+Cu.import("resource://gre/modules/XPCOMUtils.jsm"); /*global: XPCOMUtils */
+
+const Log = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.bind("Push");
+
+const {
+ PushCrypto,
+ base64UrlDecode,
+ concatArray,
+ getEncryptionKeyParams,
+ getEncryptionParams,
+} = Cu.import("resource://gre/modules/PushCrypto.jsm");
+
+this.EXPORTED_SYMBOLS = ["PushServiceAndroidGCM"];
+
+XPCOMUtils.defineLazyGetter(this, "console", () => {
+ let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
+ return new ConsoleAPI({
+ dump: Log.i,
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "PushServiceAndroidGCM",
+ });
+});
+
+const kPUSHANDROIDGCMDB_DB_NAME = "pushAndroidGCM";
+const kPUSHANDROIDGCMDB_DB_VERSION = 5; // Change this if the IndexedDB format changes
+const kPUSHANDROIDGCMDB_STORE_NAME = "pushAndroidGCM";
+
+const prefs = new Preferences("dom.push.");
+
+/**
+ * The implementation of WebPush push backed by Android's GCM
+ * delivery.
+ */
+this.PushServiceAndroidGCM = {
+ _mainPushService: null,
+ _serverURI: null,
+
+ newPushDB: function() {
+ return new PushDB(kPUSHANDROIDGCMDB_DB_NAME,
+ kPUSHANDROIDGCMDB_DB_VERSION,
+ kPUSHANDROIDGCMDB_STORE_NAME,
+ "channelID",
+ PushRecordAndroidGCM);
+ },
+
+ serviceType: function() {
+ return "AndroidGCM";
+ },
+
+ validServerURI: function(serverURI) {
+ if (!serverURI) {
+ return false;
+ }
+
+ if (serverURI.scheme == "https") {
+ return true;
+ }
+ if (prefs.get("debug") && serverURI.scheme == "http") {
+ // Accept HTTP endpoints when debugging.
+ return true;
+ }
+ console.info("Unsupported Android GCM dom.push.serverURL scheme", serverURI.scheme);
+ return false;
+ },
+
+ observe: function(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ if (data == "dom.push.debug") {
+ // Reconfigure.
+ let debug = prefs.get("debug");
+ console.info("Debug parameter changed; updating configuration with new debug", debug);
+ this._configure(this._serverURI, debug);
+ return;
+ }
+ }
+
+ if (topic == "PushServiceAndroidGCM:ReceivedPushMessage") {
+ // XXX use Messaging.jsm for this.
+ if (this._mainPushService == null) {
+ // Shouldn't ever happen, but let's be careful. XXX really?
+ console.error("No main PushService! Dropping message.");
+ return;
+ }
+ if (!data) {
+ console.error("No data from Java! Dropping message.");
+ return;
+ }
+ data = JSON.parse(data);
+ console.debug("ReceivedPushMessage with data", data);
+
+ // Default is no data (and no encryption).
+ let message = null;
+ let cryptoParams = null;
+
+ if (data.message && data.enc && data.enckey) {
+ // But we might have encrypted data.
+ let keymap = getEncryptionKeyParams(data.enckey);
+ if (!keymap) {
+ console.warn("ReceivedPushMessage with encrypted data but no keymap! Dropping message.");
+ return;
+ }
+ let enc = getEncryptionParams(data.enc);
+ if (!enc || !enc.keyid) {
+ console.warn("ReceivedPushMessage with encrypted data but no encryption parameters! Dropping message.");
+ return;
+ }
+ let dh = keymap[enc.keyid];
+ let salt = enc.salt;
+ let rs = (enc.rs)? parseInt(enc.rs, 10) : 4096;
+ if (!dh || !salt || isNaN(rs) || (rs <= 1)) {
+ console.warn("ReceivedPushMessage with encrypted data and bad encryption parameters! Dropping message.");
+ return;
+ }
+ cryptoParams = {
+ dh: dh,
+ salt: salt,
+ rs: rs
+ };
+ // Ciphertext is (urlsafe) Base 64 encoded.
+ message = base64UrlDecode(data.message);
+ }
+
+ console.debug("Delivering message to main PushService:", message, cryptoParams);
+ this._mainPushService.receivedPushMessage(
+ data.channelID, message, cryptoParams, (record) => {
+ // Always update the stored record.
+ return record;
+ });
+ return;
+ }
+ },
+
+ _configure: function(serverURL, debug) {
+ return Messaging.sendRequestForResult({
+ type: "PushServiceAndroidGCM:Configure",
+ endpoint: serverURL.spec,
+ debug: debug,
+ });
+ },
+
+ init: function(options, mainPushService, serverURL) {
+ console.debug("init()");
+ this._mainPushService = mainPushService;
+ this._serverURI = serverURL;
+
+ prefs.observe("debug", this);
+ Services.obs.addObserver(this, "PushServiceAndroidGCM:ReceivedPushMessage", false);
+
+ return this._configure(serverURL, prefs.get("debug"));
+ },
+
+ uninit: function() {
+ console.debug("uninit()");
+ this._mainPushService = null;
+ Services.obs.removeObserver(this, "PushServiceAndroidGCM:ReceivedPushMessage");
+ prefs.ignore("debug", this);
+ },
+
+ onAlarmFired: function() {
+ // No action required.
+ },
+
+ connect: function(records) {
+ console.debug("connect:", records);
+ // It's possible for the registration or subscriptions backing the
+ // PushService to not be registered with the underlying AndroidPushService.
+ // Expire those that are unrecognized.
+ return Messaging.sendRequestForResult({
+ type: "PushServiceAndroidGCM:DumpSubscriptions",
+ })
+ .then(subscriptions => {
+ console.debug("connect:", subscriptions);
+ // subscriptions maps chid => subscription data.
+ return Promise.all(records.map(record => {
+ if (subscriptions.hasOwnProperty(record.channelID)) {
+ console.debug("connect:", "hasOwnProperty", record.channelID);
+ return Promise.resolve();
+ }
+ console.debug("connect:", "!hasOwnProperty", record.channelID);
+ // Subscription is known to PushService.jsm but not to AndroidPushService. Drop it.
+ return this._mainPushService.dropRegistrationAndNotifyApp(record.keyID)
+ .catch(error => {
+ console.error("connect: Error dropping registration", record.keyID, error);
+ });
+ }));
+ });
+ },
+
+ isConnected: function() {
+ return this._mainPushService != null;
+ },
+
+ disconnect: function() {
+ console.debug("disconnect");
+ },
+
+ request: function(action, record) {
+ switch (action) {
+ case "register":
+ console.debug("register:", record);
+ return this._register(record);
+ case "unregister":
+ console.debug("unregister: ", record);
+ return this._unregister(record);
+ default:
+ console.debug("Ignoring unrecognized request action:", action);
+ }
+ },
+
+ _register: function(record) {
+ let ctime = Date.now();
+ // Caller handles errors.
+ return Messaging.sendRequestForResult({
+ type: "PushServiceAndroidGCM:SubscribeChannel",
+ }).then(data => {
+ console.debug("Got data:", data);
+ return PushCrypto.generateKeys()
+ .then(exportedKeys =>
+ new PushRecordAndroidGCM({
+ // Straight from autopush.
+ channelID: data.channelID,
+ pushEndpoint: data.endpoint,
+ // Common to all PushRecord implementations.
+ scope: record.scope,
+ originAttributes: record.originAttributes,
+ quota: record.maxQuota,
+ ctime: ctime,
+ // Cryptography!
+ p256dhPublicKey: exportedKeys[0],
+ p256dhPrivateKey: exportedKeys[1]
+ })
+ );
+ });
+ },
+
+ _unregister: function(record) {
+ return Messaging.sendRequestForResult({
+ type: "PushServiceAndroidGCM:UnsubscribeChannel",
+ channelID: record.channelID
+ });
+ }
+};
+
+function PushRecordAndroidGCM(record) {
+ PushRecord.call(this, record);
+ this.channelID = record.channelID;
+}
+
+PushRecordAndroidGCM.prototype = Object.create(PushRecord.prototype, {
+ keyID: {
+ get() {
+ return this.channelID;
+ },
+ },
+});
+
+// XXX -- should we expose the channelID in this way? To both places?
+PushRecordAndroidGCM.prototype.toRegistration = function() {
+ let registration = PushRecord.prototype.toRegistration.call(this);
+ registration.channelID = this.channelID;
+ return registration;
+};
+
+PushRecordAndroidGCM.prototype.toRegister = function() {
+ let register = PushRecord.prototype.toRegister.call(this);
+ register.channelID = this.channelID;
+ return register;
+};
+
+// Test cases:
+// 1. Change debug pref in Gecko.
+// 2. Change serverURL in Gecko.
+// 3. Change GCM token with Gecko not running.
+// We should be able to update registration without impacting subscriptions.
+// 4. Change sender ID with Gecko not running.
+// We must drop subscriptions. Gecko should witness missing subscriptions and expire them when it starts.
+// 5. Force 401 Bad auth errors.
+// We must drop registration (and hence subscriptions). Gecko should witness missing subscriptions and expire them when it starts.
+// 6. Registration doesn't complete until we have a subscription.
+// 7. Verify that we stop registering (and accepting pushes) when we have no subscriptions.
--- a/dom/push/moz.build
+++ b/dom/push/moz.build
@@ -13,16 +13,22 @@ EXTRA_JS_MODULES += [
'PushCrypto.jsm',
'PushDB.jsm',
'PushRecord.jsm',
'PushService.jsm',
'PushServiceHttp2.jsm',
'PushServiceWebSocket.jsm',
]
+if CONFIG['MOZ_BUILD_APP'] == 'mobile/android':
+ # Fennec only for now.
+ EXTRA_JS_MODULES += [
+ 'PushServiceAndroidGCM.jsm',
+ ]
+
MOCHITEST_MANIFESTS += [
'test/mochitest.ini',
]
XPCSHELL_TESTS_MANIFESTS += [
'test/xpcshell/xpcshell.ini',
]
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -372,10 +372,14 @@
#ifdef MOZ_ANDROID_SEARCH_ACTIVITY
#include ../search/manifests/SearchAndroidManifest_services.xml.in
#endif
#ifdef MOZ_ANDROID_MLS_STUMBLER
#include ../stumbler/manifests/StumblerManifest_services.xml.in
#endif
+#ifdef MOZ_ANDROID_GCM
+#include GcmAndroidManifest_services.xml.in
+#endif
+
</application>
</manifest>
--- a/mobile/android/base/FennecManifest_permissions.xml.in
+++ b/mobile/android/base/FennecManifest_permissions.xml.in
@@ -5,16 +5,20 @@
they can be easily shared between the two APKs. -->
#include ../services/manifests/FxAccountAndroidManifest_permissions.xml.in
#ifdef MOZ_ANDROID_SEARCH_ACTIVITY
#include ../search/manifests/SearchAndroidManifest_permissions.xml.in
#endif
+#ifdef MOZ_ANDROID_GCM
+#include GcmAndroidManifest_permissions.xml.in
+#endif
+
<!-- A signature level permission specific to each Firefox version (Android
package name, e.g., org.mozilla.firefox). Use this permission to
broadcast securely within a single Firefox version. This needs to
agree with GlobalConstants.PER_ANDROID_PACKAGE_PERMISSION. -->
<permission
android:name="@ANDROID_PACKAGE_NAME@.permission.PER_ANDROID_PACKAGE"
android:protectionLevel="signature"/>
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/GcmAndroidManifest_permissions.xml.in
@@ -0,0 +1,4 @@
+ <uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
+ <!-- Avoid a linter warning by not double-including WAKE_LOCK.
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
+ -->
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/GcmAndroidManifest_services.xml.in
@@ -0,0 +1,29 @@
+ <!-- Handle GCM registration updates from on-device Google Play Services. -->
+ <service
+ android:name="org.mozilla.gecko.gcm.GcmInstanceIDListenerService"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="com.google.android.gms.iid.InstanceID"/>
+ </intent-filter>
+ </service>
+
+ <!-- Provided by on-device Google Play Services. Directs inbound messages to internal listener service. -->
+ <receiver
+ android:name="com.google.android.gms.gcm.GcmReceiver"
+ android:exported="true"
+ android:permission="com.google.android.c2dm.permission.SEND">
+ <intent-filter>
+ <action android:name="com.google.android.c2dm.intent.RECEIVE" />
+ <action android:name="com.google.android.c2dm.intent.REGISTRATION" />
+ <category android:name="@ANDROID_PACKAGE_NAME@" />
+ </intent-filter>
+ </receiver>
+
+ <!-- Handle messages directed by the GCM receiver. -->
+ <service
+ android:name="org.mozilla.gecko.gcm.GcmMessageListenerService"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="com.google.android.c2dm.intent.RECEIVE" />
+ </intent-filter>
+ </service>
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
@@ -1,36 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.gecko;
-import org.mozilla.gecko.AdjustConstants;
-import org.mozilla.gecko.AppConstants;
-import org.mozilla.gecko.db.BrowserContract;
-import org.mozilla.gecko.db.BrowserDB;
-import org.mozilla.gecko.db.LocalBrowserDB;
-import org.mozilla.gecko.dlc.DownloadContentService;
-import org.mozilla.gecko.home.HomePanelsManager;
-import org.mozilla.gecko.lwt.LightweightTheme;
-import org.mozilla.gecko.mdns.MulticastDNSManager;
-import org.mozilla.gecko.util.Clipboard;
-import org.mozilla.gecko.util.HardwareUtils;
-import org.mozilla.gecko.util.ThreadUtils;
-
import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.os.SystemClock;
import android.util.Log;
import com.squareup.leakcanary.LeakCanary;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.LocalBrowserDB;
+import org.mozilla.gecko.dlc.DownloadContentService;
+import org.mozilla.gecko.home.HomePanelsManager;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.mdns.MulticastDNSManager;
+import org.mozilla.gecko.push.PushService;
+import org.mozilla.gecko.util.Clipboard;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
import java.io.File;
public class GeckoApplication extends Application
implements ContextGetter {
private static final String LOG_TAG = "GeckoApplication";
private static volatile GeckoApplication instance;
@@ -161,16 +160,38 @@ public class GeckoApplication extends Ap
return new LocalBrowserDB(profileName);
}
});
GeckoService.register();
super.onCreate();
+ if (AppConstants.MOZ_ANDROID_GCM) {
+ // android.os.Process.myUserHandle().
+ // TODO: only run in main process.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ // It's fine to throw GCM initialization onto a background thread; the registration process requires
+ // network access, so is naturally asynchronous. This, of course, races against Gecko page load of
+ // content requiring GCM-backed services, like Web Push. There's nothing to be done here.
+ PushService.createInstance(context);
+ PushService.register();
+
+ try {
+ PushService.getInstance().onStartup();
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
+ return;
+ }
+ }
+ });
+ }
+
if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
DownloadContentService.startStudy(this);
}
}
public boolean isApplicationInBackground() {
return mInBackground;
}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmInstanceIDListenerService.java
@@ -0,0 +1,37 @@
+/* -*- 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.gcm;
+
+import android.util.Log;
+
+import com.google.android.gms.iid.InstanceIDListenerService;
+
+import org.mozilla.gecko.push.PushService;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * This service is notified by the on-device Google Play Services library if an
+ * in-use token needs to be updated. We simply pass through to AndroidPushService.
+ */
+public class GcmInstanceIDListenerService extends InstanceIDListenerService {
+ private static final String LOG_TAG = "GeckoGCMInstanceID";
+
+ /**
+ * Called if InstanceID token is updated. This may occur if the security of
+ * the previous token had been compromised. This call is initiated by the
+ * InstanceID provider.
+ */
+ @Override
+ public void onTokenRefresh() {
+ Log.d("GeckoPushGCM", "Token refresh request received. Processing on background thread.");
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ PushService.getInstance().onRefresh();
+ }
+ });
+ }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmMessageListenerService.java
@@ -0,0 +1,37 @@
+/* -*- 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.gcm;
+
+import android.os.Bundle;
+import android.util.Log;
+
+import com.google.android.gms.gcm.GcmListenerService;
+
+import org.mozilla.gecko.push.PushService;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * This service actually handles messages directed from the on-device Google
+ * Play Services package. We simply route them to the AndroidPushService.
+ */
+public class GcmMessageListenerService extends GcmListenerService {
+ /**
+ * Called when message is received.
+ *
+ * @param from SenderID of the sender.
+ * @param bundle Data bundle containing message data as key/value pairs.
+ */
+ @Override
+ public void onMessageReceived(final String from, final Bundle bundle) {
+ Log.d("GeckoPushGCM", "Message received. Processing on background thread.");
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ PushService.getInstance().onMessageReceived(bundle);
+ }
+ });
+ }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmTokenClient.java
@@ -0,0 +1,87 @@
+/* -*- 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.gcm;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.GoogleApiAvailability;
+import com.google.android.gms.gcm.GoogleCloudMessaging;
+import com.google.android.gms.iid.InstanceID;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+import java.io.IOException;
+import java.util.HashMap;
+
+/**
+ * TODO: class comments.
+ */
+public class GcmTokenClient {
+ private static final String LOG_TAG = GcmTokenClient.class.getSimpleName();
+ private final Context context;
+
+ // Map senderID -> token.
+ private final HashMap<String, String> tokenCache = new HashMap<>();
+
+ private InstanceID instanceID = null; // Lazily initialized.
+
+ public GcmTokenClient(Context context) {
+ this.context = context;
+ }
+
+ /**
+ * Check the device to make sure it has the Google Play Services APK.
+ * @param context Android context.
+ */
+ protected void ensurePlayServices(Context context) throws NeedsGooglePlayServicesException {
+ final GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance();
+ int resultCode = apiAvailability.isGooglePlayServicesAvailable(context);
+ if (resultCode != ConnectionResult.SUCCESS) {
+ Logger.warn(LOG_TAG, "This device does not support GCM! isGooglePlayServicesAvailable returned: " + resultCode);
+ Logger.warn(LOG_TAG, "isGooglePlayServicesAvailable message: " + apiAvailability.getErrorString(resultCode));
+ throw new NeedsGooglePlayServicesException(resultCode);
+ }
+ }
+
+ // Note: https://developers.google.com/cloud-messaging/android/legacy-regid and App version.
+ public @NonNull String getToken(@NonNull String senderID) throws NeedsGooglePlayServicesException, IOException {
+ ensurePlayServices(this.context);
+
+ String token = tokenCache.get(senderID);
+ if (token != null) {
+ Logger.info(LOG_TAG, "Cached GCM token exists: " + token);
+ return token;
+ }
+
+ Logger.info(LOG_TAG, "Cached GCM token does not exist; requesting new token with sender ID: " + senderID);
+
+ if (instanceID == null) {
+ instanceID = InstanceID.getInstance(context);
+ }
+
+ token = instanceID.getToken(senderID, GoogleCloudMessaging.INSTANCE_ID_SCOPE, null);
+ tokenCache.put(senderID, token);
+ return token;
+ }
+
+ public class NeedsGooglePlayServicesException extends Exception {
+ private static final long serialVersionUID = 4132853166L;
+
+ private final int resultCode;
+
+ NeedsGooglePlayServicesException(int resultCode) {
+ super();
+ this.resultCode = resultCode;
+ }
+
+ public void showErrorNotification() {
+ final GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance();
+ apiAvailability.showErrorNotification(context, resultCode);
+ }
+ }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
@@ -0,0 +1,314 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.gcm.GcmTokenClient;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Class that handles messages used in the Google Cloud Messaging and DOM push API integration.
+ * <p/>
+ * This class services Gecko messages from dom/push/PushServiceAndroidGCM.jsm and Google Cloud
+ * Messaging requests.
+ * <p/>
+ * It's worth noting that we allow the DOM push API in restricted profiles.
+ */
+public class PushService implements BundleEventListener {
+ private static final String LOG_TAG = "GeckoPushService";
+
+ public static final String SERVICE_WEBPUSH = "webpush"; // TODO: coalesce constants.
+ public static final String SERVICE_SYNC = "sync";
+
+ private static PushService sInstance;
+
+ private static final String[] GECKO_EVENTS = new String[]{
+ "PushServiceAndroidGCM:Configure",
+ "PushServiceAndroidGCM:DumpRegistration",
+ "PushServiceAndroidGCM:DumpSubscriptions",
+ "PushServiceAndroidGCM:RegisterUserAgent",
+ "PushServiceAndroidGCM:UnregisterUserAgent",
+ "PushServiceAndroidGCM:SubscribeChannel",
+ "PushServiceAndroidGCM:UnsubscribeChannel",
+ };
+
+ public static synchronized PushService getInstance() {
+ if (sInstance == null) {
+ throw new IllegalStateException("PushService not yet created!");
+ }
+ return sInstance;
+ }
+
+ public static synchronized PushService createInstance(Context context) {
+ if (sInstance != null) {
+ throw new IllegalStateException("PushService already created!");
+ }
+ sInstance = new PushService(context);
+ return sInstance;
+ }
+
+ protected final PushManager pushManager;
+
+ public PushService(Context context) {
+ pushManager = new PushManager(new PushState(context, "pushState.json"), new GcmTokenClient(context), new PushManager.PushClientFactory() {
+ @Override
+ public PushClient getPushClient(String autopushEndpoint, boolean debug) {
+ return new PushClient(autopushEndpoint);
+ }
+ });
+ }
+
+
+ public void onStartup() {
+ Log.i(LOG_TAG, "Starting up.");
+ ThreadUtils.assertOnBackgroundThread();
+
+ try {
+ pushManager.startup(System.currentTimeMillis());
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
+ return;
+ }
+ }
+
+ public void onRefresh() {
+ Log.i(LOG_TAG, "Google Play Services requested GCM token refresh; running startup again.");
+ ThreadUtils.assertOnBackgroundThread();
+
+ try {
+ pushManager.startup(System.currentTimeMillis());
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
+ return;
+ }
+ }
+
+ public void onMessageReceived(final @NonNull Bundle bundle) {
+ Log.i(LOG_TAG, "Google Play Services GCM message received; delivering.");
+ ThreadUtils.assertOnBackgroundThread();
+
+ final String chid = bundle.getString("chid");
+ if (chid == null) {
+ Log.w(LOG_TAG, "No chid found; ignoring message.");
+ return;
+ }
+
+ final PushRegistration registration = pushManager.registrationForSubscription(chid);
+ if (registration == null) {
+ Log.w(LOG_TAG, "Cannot find registration corresponding to subscription for chid: " + chid + "; ignoring message.");
+ return;
+ }
+
+ final PushSubscription subscription = registration.getSubscription(chid);
+ if (subscription == null) {
+ // This should never happen. There's not much to be done; in the future, perhaps we
+ // could try to drop the remote subscription?
+ Log.e(LOG_TAG, "No subscription found for chid: " + chid + "; ignoring message.");
+ return;
+ }
+
+ Log.i(LOG_TAG, "Message directed to service: " + subscription.service);
+
+ if (SERVICE_SYNC.equals(subscription.service)) {
+ // Nothing yet.
+ return;
+ }
+
+ if (SERVICE_WEBPUSH.equals(subscription.service)) {
+ if (subscription.serviceData == null) {
+ Log.e(LOG_TAG, "No serviceData found for chid: " + chid + "; ignoring dom/push message.");
+ return;
+ }
+
+ final String profileName = subscription.serviceData.optString("profileName", null);
+ final String profilePath = subscription.serviceData.optString("profilePath", null);
+ if (profileName == null || profilePath == null) {
+ Log.e(LOG_TAG, "Corrupt serviceData found for chid: " + chid + "; ignoring dom/push message.");
+ return;
+ }
+
+ if (!GeckoThread.isRunning()) {
+ Log.w(LOG_TAG, "dom/push message received but no Gecko thread is running; ignoring message.");
+ return;
+ }
+
+ final GeckoAppShell.GeckoInterface geckoInterface = GeckoAppShell.getGeckoInterface();
+ if (geckoInterface == null) {
+ Log.w(LOG_TAG, "dom/push message received but no Gecko interface is registered; ignoring message.");
+ return;
+ }
+
+ final GeckoProfile profile = geckoInterface.getProfile();
+ if (profile == null || !profileName.equals(profile.getName()) || !profilePath.equals(profile.getDir().getAbsolutePath())) {
+ Log.w(LOG_TAG, "dom/push message received but Gecko is running with the wrong profile name or path; ignoring message.");
+ return;
+ }
+
+ // DELIVERANCE!
+ final JSONObject data = new JSONObject();
+ try {
+ data.put("channelID", chid);
+ data.put("enc", bundle.getString("enc"));
+ data.put("enckey", bundle.getString("enckey"));
+ data.put("message", bundle.getString("body"));
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Got exception delivering dom/push message to Gecko!", e);
+ return;
+ }
+
+ Log.i(LOG_TAG, "Delivering dom/push message to Gecko!");
+ GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("PushServiceAndroidGCM:ReceivedPushMessage", data.toString()));
+ }
+ }
+
+ public static void register() {
+ Log.d(LOG_TAG, "Registered listener.");
+ EventDispatcher.getInstance().registerBackgroundThreadListener(getInstance(), GECKO_EVENTS);
+ }
+
+ public static void unregister() {
+ Log.d(LOG_TAG, "Unregistered listener.");
+ EventDispatcher.getInstance().unregisterBackgroundThreadListener(getInstance(), GECKO_EVENTS);
+ }
+
+ @Override
+ public void handleMessage(final String event, final Bundle message, final EventCallback callback) {
+ Log.i(LOG_TAG, "Handling event: " + event);
+ ThreadUtils.assertOnBackgroundThread();
+
+ // We're invoked in response to a Gecko message on a background thread. We should always
+ // be able to safely retrieve the current Gecko profile.
+ final GeckoProfile geckoProfile = GeckoProfile.get(GeckoAppShell.getApplicationContext());
+
+ // TODO: errors are objects with a .error string field. Do this in the JSM.
+ if (callback == null) {
+ Log.e(LOG_TAG, "callback must not be null in " + event);
+ return;
+ }
+
+ try {
+ if ("PushServiceAndroidGCM:Configure".equals(event)) {
+ final String endpoint = message.getString("endpoint");
+ if (endpoint == null) {
+ Log.e(LOG_TAG, "endpoint must not be null in " + event);
+ return;
+ }
+ final boolean debug = message.getBoolean("debug", false);
+ pushManager.configure(geckoProfile.getName(), endpoint, debug, System.currentTimeMillis()); // For side effects.
+ callback.sendSuccess(null);
+ return;
+ } else if ("PushServiceAndroidGCM:DumpRegistration".equals(event)) {
+ // TODO: implement this!
+ } else if ("PushServiceAndroidGCM:DumpSubscriptions".equals(event)) {
+ try {
+ final Map<String, PushSubscription> result = pushManager.allSubscriptionsForProfile(geckoProfile.getName());
+
+ final JSONObject json = new JSONObject();
+ for (Map.Entry<String, PushSubscription> entry : result.entrySet()) {
+ json.put(entry.getKey(), entry.getValue().toJSONObject());
+ }
+ callback.sendSuccess(json);
+ return;
+ } catch (JSONException e) {
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ return;
+ }
+ } else if ("PushServiceAndroidGCM:RegisterUserAgent".equals(event)) {
+ try {
+ final PushRegistration registration = pushManager.registerUserAgent(geckoProfile.getName(), System.currentTimeMillis());
+ callback.sendSuccess(null);
+ return;
+ } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ return;
+ }
+ } else if ("PushServiceAndroidGCM:UnregisterUserAgent".equals(event)) {
+ callback.sendError("Not yet implemented!");
+ return;
+ } else if ("PushServiceAndroidGCM:SubscribeChannel".equals(event)) {
+ final String service = SERVICE_WEBPUSH;
+ final JSONObject serviceData;
+ try {
+ serviceData = new JSONObject();
+ serviceData.put("profileName", geckoProfile.getName());
+ serviceData.put("profilePath", geckoProfile.getDir().getAbsolutePath());
+ } catch (JSONException e) {
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ return;
+ }
+
+ final PushSubscription subscription;
+ try {
+ subscription = pushManager.subscribeChannel(geckoProfile.getName(), service, serviceData, System.currentTimeMillis());
+ } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ return;
+ }
+
+ final JSONObject json = new JSONObject();
+ try {
+ json.put("channelID", subscription.chid);
+ json.put("endpoint", subscription.webpushEndpoint);
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Got exception in " + event, e);
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ return;
+ }
+ callback.sendSuccess(json);
+ } else if ("PushServiceAndroidGCM:UnsubscribeChannel".equals(event)) {
+ callback.sendError("Not yet implemented!");
+ return;
+ }
+ } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
+ // XXX TODO: improve this. Can we find a point where the user is *definitely*
+ // interacting with the WebPush? Perhaps we can show a dialog when interacting with
+ // the Push permissions, and then be more aggressive showing this notification when
+ // we have registrations and subscriptions that can't be advanced.
+ e.showErrorNotification();
+ callback.sendError("To handle event [" + event + "], user interaction is needed to enable Google Play Services.");
+ }
+ }
+
+// /**
+// * Create and show a simple notification containing the received GCM message.
+// *
+// * @param message GCM message received.
+// */
+// private void sendNotification(String message) {
+// final Intent intent = new Intent(this, BrowserApp.class);
+// intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+// final PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent, PendingIntent.FLAG_ONE_SHOT);
+//
+// final Uri defaultSoundUri= RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
+// NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this)
+// .setSmallIcon(R.drawable.ic_status_logo)
+// .setContentTitle("GCM Message")
+// .setContentText(message)
+// .setAutoCancel(true)
+// .setSound(defaultSoundUri)
+// .setContentIntent(pendingIntent);
+//
+// final NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+// notificationManager.notify(0 /* ID of notification */, notificationBuilder.build());
+// }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/util/GeckoBackgroundThread.java
+++ b/mobile/android/base/java/org/mozilla/gecko/util/GeckoBackgroundThread.java
@@ -4,17 +4,17 @@
package org.mozilla.gecko.util;
import android.os.Handler;
import android.os.Looper;
import java.util.concurrent.SynchronousQueue;
-final class GeckoBackgroundThread extends Thread {
+public final class GeckoBackgroundThread extends Thread {
private static final String LOOPER_NAME = "GeckoBackgroundThread";
// Guarded by 'GeckoBackgroundThread.class'.
private static Handler handler;
private static Thread thread;
// The initial Runnable to run on the new thread. Its purpose
// is to avoid us having to wait for the new thread to start.
@@ -47,17 +47,17 @@ final class GeckoBackgroundThread extend
thread = new GeckoBackgroundThread(initialRunnable);
ThreadUtils.setBackgroundThread(thread);
thread.setDaemon(true);
thread.start();
}
// Get a Handler for a looper thread, or create one if it doesn't yet exist.
- /*package*/ static synchronized Handler getHandler() {
+ public static synchronized Handler getHandler() {
if (thread == null) {
startThread(null);
}
while (handler == null) {
try {
GeckoBackgroundThread.class.wait();
} catch (final InterruptedException e) {
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -655,16 +655,30 @@ gbjar.extra_jars += [
CONFIG['ANDROID_SUPPORT_V4_AAR_LIB'],
CONFIG['ANDROID_SUPPORT_V4_AAR_INTERNAL_LIB'],
'constants.jar'
]
if CONFIG['MOZ_CRASHREPORTER']:
gbjar.sources += [ 'java/org/mozilla/gecko/CrashReporter.java' ]
ANDROID_RES_DIRS += [ 'crashreporter/res' ]
+if CONFIG['MOZ_ANDROID_GCM']:
+ gbjar.sources += ['java/org/mozilla/gecko/' + x for x in [
+ 'gcm/GcmInstanceIDListenerService.java',
+ 'gcm/GcmMessageListenerService.java',
+ 'gcm/GcmTokenClient.java',
+ 'push/Fetched.java',
+ 'push/PushClient.java',
+ 'push/PushManager.java',
+ 'push/PushRegistration.java',
+ 'push/PushService.java',
+ 'push/PushState.java',
+ 'push/PushSubscription.java',
+ ]]
+
if (CONFIG['MOZ_ANDROID_MAX_SDK_VERSION']):
max_sdk_version = int(CONFIG['MOZ_ANDROID_MAX_SDK_VERSION'])
else:
max_sdk_version = 999
# Only bother to include new tablet code if we're building for tablet-capable
# OS releases.
if max_sdk_version >= 11:
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClientException.java
@@ -16,16 +16,20 @@ public class AutopushClientException ext
public AutopushClientException(String detailMessage) {
super(detailMessage);
}
public AutopushClientException(Exception e) {
super(e);
}
+ public boolean isTransientError() {
+ return false;
+ }
+
public static class AutopushClientRemoteException extends AutopushClientException {
private static final long serialVersionUID = 2209313149952001000L;
public final HttpResponse response;
public final long httpStatusCode;
public final long apiErrorNumber;
public final String error;
public final String message;
@@ -51,16 +55,21 @@ public class AutopushClientException ext
public boolean isInvalidAuthentication() {
return httpStatusCode == HttpStatus.SC_UNAUTHORIZED;
}
public boolean isNotFound() {
return httpStatusCode == HttpStatus.SC_NOT_FOUND;
}
+
+ @Override
+ public boolean isTransientError() {
+ return httpStatusCode >= 500;
+ }
}
public static class AutopushClientMalformedResponseException extends AutopushClientRemoteException {
private static final long serialVersionUID = 2209313149952001909L;
public AutopushClientMalformedResponseException(HttpResponse response) {
super(response, 0, 999, "Response malformed", "Response malformed", new ExtendedJSONObject());
}
--- 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
@@ -28,17 +28,17 @@ 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.")
+// @Ignore("Live test that requires network connection -- remove this line to run this test.")
public class TestLiveAutopushClient {
final String serverURL = "https://updates-autopush-dev.stage.mozaws.net/v1/gcm/829133274407";
protected AutopushClient client;
@Before
public void setUp() throws Exception {
BaseResource.rewriteLocalhost = false;