/* -*- 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.content.Intent; 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.GeckoProfile; import org.mozilla.gecko.GeckoService; import org.mozilla.gecko.GeckoThread; import org.mozilla.gecko.Telemetry; import org.mozilla.gecko.TelemetryContract; import org.mozilla.gecko.annotation.ReflectionTarget; import org.mozilla.gecko.db.BrowserDB; import org.mozilla.gecko.fxa.FxAccountPushHandler; 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.File; import java.io.IOException; import java.util.LinkedList; import java.util.List; import java.util.Map; /** * Class that handles messages used in the Google Cloud Messaging and DOM push API integration. *

* This singleton services Gecko messages from dom/push/PushServiceAndroidGCM.jsm and Google Cloud * Messaging requests. *

* It is expected that Gecko is started (if not already running) soon after receiving GCM messages * otherwise there is a greater risk that pending messages that have not been handle by Gecko will * be lost if this service is killed. *

* It's worth noting that we allow the DOM push API in restricted profiles. */ @ReflectionTarget public class PushService implements BundleEventListener { private static final String LOG_TAG = "GeckoPushService"; public static final String SERVICE_WEBPUSH = "webpush"; public static final String SERVICE_FXA = "fxa"; private static PushService sInstance; private static final String[] GECKO_EVENTS = new String[] { "PushServiceAndroidGCM:Configure", "PushServiceAndroidGCM:DumpRegistration", "PushServiceAndroidGCM:DumpSubscriptions", "PushServiceAndroidGCM:Initialized", "PushServiceAndroidGCM:Uninitialized", "PushServiceAndroidGCM:RegisterUserAgent", "PushServiceAndroidGCM:UnregisterUserAgent", "PushServiceAndroidGCM:SubscribeChannel", "PushServiceAndroidGCM:UnsubscribeChannel", "FxAccountsPush:Initialized", "FxAccountsPush:ReceivedPushMessageToDecode:Response", "History:GetPrePathLastVisitedTimeMilliseconds", }; private enum GeckoComponent { FxAccountsPush, PushServiceAndroidGCM } public static synchronized PushService getInstance(Context context) { if (sInstance == null) { onCreate(context); } return sInstance; } @ReflectionTarget public static synchronized void onCreate(Context context) { if (sInstance != null) { return; } sInstance = new PushService(context); sInstance.registerGeckoEventListener(); sInstance.onStartup(); } protected final PushManager pushManager; // NB: These are not thread-safe, we're depending on these being access from the same background thread. private boolean isReadyPushServiceAndroidGCM = false; private boolean isReadyFxAccountsPush = false; private final List pendingPushMessages; public PushService(Context context) { pushManager = new PushManager(new PushState(context, "GeckoPushState.json"), new GcmTokenClient(context), new PushManager.PushClientFactory() { @Override public PushClient getPushClient(String autopushEndpoint, boolean debug) { return new PushClient(autopushEndpoint); } }); pendingPushMessages = new LinkedList<>(); } 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; invalidating GCM token and running startup again."); ThreadUtils.assertOnBackgroundThread(); pushManager.invalidateGcmToken(); try { pushManager.startup(System.currentTimeMillis()); } catch (Exception e) { Log.e(LOG_TAG, "Got exception during refresh; ignoring.", e); return; } } public void onMessageReceived(final @NonNull Context context, 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; } boolean isWebPush = SERVICE_WEBPUSH.equals(subscription.service); boolean isFxAPush = SERVICE_FXA.equals(subscription.service); if (!isWebPush && !isFxAPush) { Log.e(LOG_TAG, "Message directed to unknown service; dropping: " + subscription.service); return; } Log.i(LOG_TAG, "Message directed to service: " + subscription.service); if (subscription.serviceData == null) { Log.e(LOG_TAG, "No serviceData found for chid: " + chid + "; ignoring dom/push message."); return; } Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.SERVICE, "dom-push-api"); 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 (canSendPushMessagesToGecko()) { if (!GeckoThread.canUseProfile(profileName, new File(profilePath))) { Log.e(LOG_TAG, "Mismatched profile for chid: " + chid + "; ignoring dom/push message."); return; } } else { final Intent intent = GeckoService.getIntentToCreateServices(context, "android-push-service"); GeckoService.setIntentProfile(intent, profileName, profilePath); context.startService(intent); } final JSONObject data = new JSONObject(); try { data.put("channelID", chid); data.put("con", bundle.getString("con")); data.put("enc", bundle.getString("enc")); // Only one of cryptokey (newer) and enckey (deprecated) should be set, but the // Gecko handler will verify this. data.put("cryptokey", bundle.getString("cryptokey")); data.put("enckey", bundle.getString("enckey")); data.put("message", bundle.getString("body")); if (!canSendPushMessagesToGecko()) { data.put("profileName", profileName); data.put("profilePath", profilePath); data.put("service", subscription.service); } } catch (JSONException e) { Log.e(LOG_TAG, "Got exception delivering dom/push message to Gecko!", e); return; } if (!canSendPushMessagesToGecko()) { Log.i(LOG_TAG, "Required service not initialized, adding message to queue."); pendingPushMessages.add(data); return; } if (isWebPush) { sendMessageToGeckoService(data); } else { sendMessageToDecodeToGeckoService(data); } } protected static void sendMessageToGeckoService(final @NonNull JSONObject message) { Log.i(LOG_TAG, "Delivering dom/push message to Gecko!"); GeckoAppShell.notifyObservers("PushServiceAndroidGCM:ReceivedPushMessage", message.toString(), GeckoThread.State.PROFILE_READY); } protected static void sendMessageToDecodeToGeckoService(final @NonNull JSONObject message) { Log.i(LOG_TAG, "Delivering dom/push message to decode to Gecko!"); GeckoAppShell.notifyObservers("FxAccountsPush:ReceivedPushMessageToDecode", message.toString(), GeckoThread.State.PROFILE_READY); } protected void registerGeckoEventListener() { Log.d(LOG_TAG, "Registered Gecko event listener."); EventDispatcher.getInstance().registerBackgroundThreadListener(this, GECKO_EVENTS); } protected void unregisterGeckoEventListener() { Log.d(LOG_TAG, "Unregistered Gecko event listener."); EventDispatcher.getInstance().unregisterBackgroundThreadListener(this, GECKO_EVENTS); } @Override public void handleMessage(final String event, final Bundle message, final EventCallback callback) { Log.i(LOG_TAG, "Handling event: " + event); ThreadUtils.assertOnBackgroundThread(); final Context context = GeckoAppShell.getApplicationContext(); // 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(context); if (callback == null) { Log.e(LOG_TAG, "callback must not be null in " + event); return; } try { if ("PushServiceAndroidGCM:Initialized".equals(event)) { processComponentState(GeckoComponent.PushServiceAndroidGCM, true); callback.sendSuccess(null); return; } if ("PushServiceAndroidGCM:Uninitialized".equals(event)) { processComponentState(GeckoComponent.PushServiceAndroidGCM, false); callback.sendSuccess(null); return; } if ("FxAccountsPush:Initialized".equals(event)) { processComponentState(GeckoComponent.FxAccountsPush, true); callback.sendSuccess(null); return; } if ("PushServiceAndroidGCM:Configure".equals(event)) { final String endpoint = message.getString("endpoint"); if (endpoint == null) { callback.sendError("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; } if ("PushServiceAndroidGCM:DumpRegistration".equals(event)) { // In the future, this might be used to interrogate the Java Push Manager // registration state from JavaScript. callback.sendError("Not yet implemented!"); return; } if ("PushServiceAndroidGCM:DumpSubscriptions".equals(event)) { try { final Map result = pushManager.allSubscriptionsForProfile(geckoProfile.getName()); final JSONObject json = new JSONObject(); for (Map.Entry entry : result.entrySet()) { json.put(entry.getKey(), entry.getValue().toJSONObject()); } callback.sendSuccess(json); } catch (JSONException e) { callback.sendError("Got exception handling message [" + event + "]: " + e.toString()); } return; } if ("PushServiceAndroidGCM:RegisterUserAgent".equals(event)) { try { pushManager.registerUserAgent(geckoProfile.getName(), System.currentTimeMillis()); // For side-effects. callback.sendSuccess(null); } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) { Log.e(LOG_TAG, "Got exception in " + event, e); callback.sendError("Got exception handling message [" + event + "]: " + e.toString()); } return; } if ("PushServiceAndroidGCM:UnregisterUserAgent".equals(event)) { // In the future, this might be used to tell the Java Push Manager to unregister // a User Agent entirely from JavaScript. Right now, however, everything is // subscription based; there's no concept of unregistering all subscriptions // simultaneously. callback.sendError("Not yet implemented!"); return; } if ("PushServiceAndroidGCM:SubscribeChannel".equals(event)) { final String service = SERVICE_FXA.equals(message.getString("service")) ? SERVICE_FXA : SERVICE_WEBPUSH; final JSONObject serviceData; final String appServerKey = message.getString("appServerKey"); try { serviceData = new JSONObject(); serviceData.put("profileName", geckoProfile.getName()); serviceData.put("profilePath", geckoProfile.getDir().getAbsolutePath()); } catch (JSONException e) { Log.e(LOG_TAG, "Got exception in " + event, e); callback.sendError("Got exception handling message [" + event + "]: " + e.toString()); return; } final PushSubscription subscription; try { subscription = pushManager.subscribeChannel(geckoProfile.getName(), service, serviceData, appServerKey, System.currentTimeMillis()); } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) { Log.e(LOG_TAG, "Got exception in " + event, 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; } Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.SERVICE, "dom-push-api"); callback.sendSuccess(json); return; } if ("PushServiceAndroidGCM:UnsubscribeChannel".equals(event)) { final String channelID = message.getString("channelID"); if (channelID == null) { callback.sendError("channelID must not be null in " + event); return; } // Fire and forget. See comments in the function itself. final PushSubscription pushSubscription = pushManager.unsubscribeChannel(channelID); if (pushSubscription != null) { Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.SERVICE, "dom-push-api"); callback.sendSuccess(null); return; } callback.sendError("Could not unsubscribe from channel: " + channelID); return; } if ("FxAccountsPush:ReceivedPushMessageToDecode:Response".equals(event)) { FxAccountPushHandler.handleFxAPushMessage(context, message); return; } if ("History:GetPrePathLastVisitedTimeMilliseconds".equals(event)) { if (callback == null) { Log.e(LOG_TAG, "callback must not be null in " + event); return; } final String prePath = message.getString("prePath"); if (prePath == null) { callback.sendError("prePath must not be null in " + event); return; } // We're on a background thread, so we can be synchronous. final long millis = BrowserDB.from(geckoProfile).getPrePathLastVisitedTimeMilliseconds( context.getContentResolver(), prePath); callback.sendSuccess(millis); return; } } catch (GcmTokenClient.NeedsGooglePlayServicesException e) { // 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. callback.sendError("To handle event [" + event + "], user interaction is needed to enable Google Play Services."); } } private void processComponentState(@NonNull GeckoComponent component, boolean isReady) { if (component == GeckoComponent.FxAccountsPush) { isReadyFxAccountsPush = isReady; } else if (component == GeckoComponent.PushServiceAndroidGCM) { isReadyPushServiceAndroidGCM = isReady; } // Send all pending messages to Gecko. if (canSendPushMessagesToGecko()) { sendPushMessagesToGecko(pendingPushMessages); pendingPushMessages.clear(); } } private boolean canSendPushMessagesToGecko() { return isReadyFxAccountsPush && isReadyPushServiceAndroidGCM; } private static void sendPushMessagesToGecko(@NonNull List messages) { for (JSONObject pushMessage : messages) { final String profileName = pushMessage.optString("profileName", null); final String profilePath = pushMessage.optString("profilePath", null); final String service = pushMessage.optString("service", null); if (profileName == null || profilePath == null || !GeckoThread.canUseProfile(profileName, new File(profilePath))) { Log.e(LOG_TAG, "Mismatched profile for chid: " + pushMessage.optString("channelID") + "; ignoring dom/push message."); continue; } if (SERVICE_WEBPUSH.equals(service)) { sendMessageToGeckoService(pushMessage); } else if (SERVICE_FXA.equals(service)) { sendMessageToDecodeToGeckoService(pushMessage); } } } }