1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
2  * This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 
6 package org.mozilla.gecko.push;
7 
8 import android.content.Context;
9 import android.content.Intent;
10 import android.os.Bundle;
11 import android.support.annotation.NonNull;
12 import android.util.Log;
13 
14 import org.json.JSONException;
15 import org.json.JSONObject;
16 import org.mozilla.gecko.EventDispatcher;
17 import org.mozilla.gecko.GeckoAppShell;
18 import org.mozilla.gecko.GeckoProfile;
19 import org.mozilla.gecko.GeckoService;
20 import org.mozilla.gecko.GeckoThread;
21 import org.mozilla.gecko.Telemetry;
22 import org.mozilla.gecko.TelemetryContract;
23 import org.mozilla.gecko.annotation.ReflectionTarget;
24 import org.mozilla.gecko.db.BrowserDB;
25 import org.mozilla.gecko.fxa.FxAccountPushHandler;
26 import org.mozilla.gecko.gcm.GcmTokenClient;
27 import org.mozilla.gecko.push.autopush.AutopushClientException;
28 import org.mozilla.gecko.util.BundleEventListener;
29 import org.mozilla.gecko.util.EventCallback;
30 import org.mozilla.gecko.util.ThreadUtils;
31 
32 import java.io.File;
33 import java.io.IOException;
34 import java.util.LinkedList;
35 import java.util.List;
36 import java.util.Map;
37 
38 /**
39  * Class that handles messages used in the Google Cloud Messaging and DOM push API integration.
40  * <p/>
41  * This singleton services Gecko messages from dom/push/PushServiceAndroidGCM.jsm and Google Cloud
42  * Messaging requests.
43  * <p/>
44  * It is expected that Gecko is started (if not already running) soon after receiving GCM messages
45  * otherwise there is a greater risk that pending messages that have not been handle by Gecko will
46  * be lost if this service is killed.
47  * <p/>
48  * It's worth noting that we allow the DOM push API in restricted profiles.
49  */
50 @ReflectionTarget
51 public class PushService implements BundleEventListener {
52     private static final String LOG_TAG = "GeckoPushService";
53 
54     public static final String SERVICE_WEBPUSH = "webpush";
55     public static final String SERVICE_FXA = "fxa";
56 
57     private static PushService sInstance;
58 
59     private static final String[] GECKO_EVENTS = new String[] {
60             "PushServiceAndroidGCM:Configure",
61             "PushServiceAndroidGCM:DumpRegistration",
62             "PushServiceAndroidGCM:DumpSubscriptions",
63             "PushServiceAndroidGCM:Initialized",
64             "PushServiceAndroidGCM:Uninitialized",
65             "PushServiceAndroidGCM:RegisterUserAgent",
66             "PushServiceAndroidGCM:UnregisterUserAgent",
67             "PushServiceAndroidGCM:SubscribeChannel",
68             "PushServiceAndroidGCM:UnsubscribeChannel",
69             "FxAccountsPush:Initialized",
70             "FxAccountsPush:ReceivedPushMessageToDecode:Response",
71             "History:GetPrePathLastVisitedTimeMilliseconds",
72     };
73 
74     private enum GeckoComponent {
75         FxAccountsPush,
76         PushServiceAndroidGCM
77     }
78 
getInstance(Context context)79     public static synchronized PushService getInstance(Context context) {
80         if (sInstance == null) {
81             onCreate(context);
82         }
83         return sInstance;
84     }
85 
86     @ReflectionTarget
onCreate(Context context)87     public static synchronized void onCreate(Context context) {
88         if (sInstance != null) {
89             return;
90         }
91         sInstance = new PushService(context);
92 
93         sInstance.registerGeckoEventListener();
94         sInstance.onStartup();
95     }
96 
97     protected final PushManager pushManager;
98 
99     // NB: These are not thread-safe, we're depending on these being access from the same background thread.
100     private boolean isReadyPushServiceAndroidGCM = false;
101     private boolean isReadyFxAccountsPush = false;
102     private final List<JSONObject> pendingPushMessages;
103 
PushService(Context context)104     public PushService(Context context) {
105         pushManager = new PushManager(new PushState(context, "GeckoPushState.json"), new GcmTokenClient(context), new PushManager.PushClientFactory() {
106             @Override
107             public PushClient getPushClient(String autopushEndpoint, boolean debug) {
108                 return new PushClient(autopushEndpoint);
109             }
110         });
111 
112         pendingPushMessages = new LinkedList<>();
113     }
114 
onStartup()115     public void onStartup() {
116         Log.i(LOG_TAG, "Starting up.");
117         ThreadUtils.assertOnBackgroundThread();
118 
119         try {
120             pushManager.startup(System.currentTimeMillis());
121         } catch (Exception e) {
122             Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
123             return;
124         }
125     }
126 
onRefresh()127     public void onRefresh() {
128         Log.i(LOG_TAG, "Google Play Services requested GCM token refresh; invalidating GCM token and running startup again.");
129         ThreadUtils.assertOnBackgroundThread();
130 
131         pushManager.invalidateGcmToken();
132         try {
133             pushManager.startup(System.currentTimeMillis());
134         } catch (Exception e) {
135             Log.e(LOG_TAG, "Got exception during refresh; ignoring.", e);
136             return;
137         }
138     }
139 
onMessageReceived(final @NonNull Context context, final @NonNull Bundle bundle)140     public void onMessageReceived(final @NonNull Context context, final @NonNull Bundle bundle) {
141         Log.i(LOG_TAG, "Google Play Services GCM message received; delivering.");
142         ThreadUtils.assertOnBackgroundThread();
143 
144         final String chid = bundle.getString("chid");
145         if (chid == null) {
146             Log.w(LOG_TAG, "No chid found; ignoring message.");
147             return;
148         }
149 
150         final PushRegistration registration = pushManager.registrationForSubscription(chid);
151         if (registration == null) {
152             Log.w(LOG_TAG, "Cannot find registration corresponding to subscription for chid: " + chid + "; ignoring message.");
153             return;
154         }
155 
156         final PushSubscription subscription = registration.getSubscription(chid);
157         if (subscription == null) {
158             // This should never happen.  There's not much to be done; in the future, perhaps we
159             // could try to drop the remote subscription?
160             Log.e(LOG_TAG, "No subscription found for chid: " + chid + "; ignoring message.");
161             return;
162         }
163 
164         boolean isWebPush = SERVICE_WEBPUSH.equals(subscription.service);
165         boolean isFxAPush = SERVICE_FXA.equals(subscription.service);
166         if (!isWebPush && !isFxAPush) {
167             Log.e(LOG_TAG, "Message directed to unknown service; dropping: " + subscription.service);
168             return;
169         }
170 
171         Log.i(LOG_TAG, "Message directed to service: " + subscription.service);
172 
173         if (subscription.serviceData == null) {
174             Log.e(LOG_TAG, "No serviceData found for chid: " + chid + "; ignoring dom/push message.");
175             return;
176         }
177 
178         Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.SERVICE, "dom-push-api");
179 
180         final String profileName = subscription.serviceData.optString("profileName", null);
181         final String profilePath = subscription.serviceData.optString("profilePath", null);
182         if (profileName == null || profilePath == null) {
183             Log.e(LOG_TAG, "Corrupt serviceData found for chid: " + chid + "; ignoring dom/push message.");
184             return;
185         }
186 
187         if (canSendPushMessagesToGecko()) {
188             if (!GeckoThread.canUseProfile(profileName, new File(profilePath))) {
189                 Log.e(LOG_TAG, "Mismatched profile for chid: " + chid + "; ignoring dom/push message.");
190                 return;
191             }
192         } else {
193             final Intent intent = GeckoService.getIntentToCreateServices(context, "android-push-service");
194             GeckoService.setIntentProfile(intent, profileName, profilePath);
195             context.startService(intent);
196         }
197 
198         final JSONObject data = new JSONObject();
199         try {
200             data.put("channelID", chid);
201             data.put("con", bundle.getString("con"));
202             data.put("enc", bundle.getString("enc"));
203             // Only one of cryptokey (newer) and enckey (deprecated) should be set, but the
204             // Gecko handler will verify this.
205             data.put("cryptokey", bundle.getString("cryptokey"));
206             data.put("enckey", bundle.getString("enckey"));
207             data.put("message", bundle.getString("body"));
208 
209             if (!canSendPushMessagesToGecko()) {
210                 data.put("profileName", profileName);
211                 data.put("profilePath", profilePath);
212                 data.put("service", subscription.service);
213             }
214         } catch (JSONException e) {
215             Log.e(LOG_TAG, "Got exception delivering dom/push message to Gecko!", e);
216             return;
217         }
218 
219         if (!canSendPushMessagesToGecko()) {
220             Log.i(LOG_TAG, "Required service not initialized, adding message to queue.");
221             pendingPushMessages.add(data);
222             return;
223         }
224 
225         if (isWebPush) {
226             sendMessageToGeckoService(data);
227         } else {
228             sendMessageToDecodeToGeckoService(data);
229         }
230     }
231 
sendMessageToGeckoService(final @NonNull JSONObject message)232     protected static void sendMessageToGeckoService(final @NonNull JSONObject message) {
233         Log.i(LOG_TAG, "Delivering dom/push message to Gecko!");
234         GeckoAppShell.notifyObservers("PushServiceAndroidGCM:ReceivedPushMessage",
235                                       message.toString(),
236                                       GeckoThread.State.PROFILE_READY);
237     }
238 
sendMessageToDecodeToGeckoService(final @NonNull JSONObject message)239     protected static void sendMessageToDecodeToGeckoService(final @NonNull JSONObject message) {
240         Log.i(LOG_TAG, "Delivering dom/push message to decode to Gecko!");
241         GeckoAppShell.notifyObservers("FxAccountsPush:ReceivedPushMessageToDecode",
242                                       message.toString(),
243                                       GeckoThread.State.PROFILE_READY);
244     }
245 
registerGeckoEventListener()246     protected void registerGeckoEventListener() {
247         Log.d(LOG_TAG, "Registered Gecko event listener.");
248         EventDispatcher.getInstance().registerBackgroundThreadListener(this, GECKO_EVENTS);
249     }
250 
unregisterGeckoEventListener()251     protected void unregisterGeckoEventListener() {
252         Log.d(LOG_TAG, "Unregistered Gecko event listener.");
253         EventDispatcher.getInstance().unregisterBackgroundThreadListener(this, GECKO_EVENTS);
254     }
255 
256     @Override
handleMessage(final String event, final Bundle message, final EventCallback callback)257     public void handleMessage(final String event, final Bundle message, final EventCallback callback) {
258         Log.i(LOG_TAG, "Handling event: " + event);
259         ThreadUtils.assertOnBackgroundThread();
260 
261         final Context context = GeckoAppShell.getApplicationContext();
262         // We're invoked in response to a Gecko message on a background thread.  We should always
263         // be able to safely retrieve the current Gecko profile.
264         final GeckoProfile geckoProfile = GeckoProfile.get(context);
265 
266         if (callback == null) {
267             Log.e(LOG_TAG, "callback must not be null in " + event);
268             return;
269         }
270 
271         try {
272             if ("PushServiceAndroidGCM:Initialized".equals(event)) {
273                 processComponentState(GeckoComponent.PushServiceAndroidGCM, true);
274                 callback.sendSuccess(null);
275                 return;
276             }
277             if ("PushServiceAndroidGCM:Uninitialized".equals(event)) {
278                 processComponentState(GeckoComponent.PushServiceAndroidGCM, false);
279                 callback.sendSuccess(null);
280                 return;
281             }
282             if ("FxAccountsPush:Initialized".equals(event)) {
283                 processComponentState(GeckoComponent.FxAccountsPush, true);
284                 callback.sendSuccess(null);
285                 return;
286             }
287             if ("PushServiceAndroidGCM:Configure".equals(event)) {
288                 final String endpoint = message.getString("endpoint");
289                 if (endpoint == null) {
290                     callback.sendError("endpoint must not be null in " + event);
291                     return;
292                 }
293                 final boolean debug = message.getBoolean("debug", false);
294                 pushManager.configure(geckoProfile.getName(), endpoint, debug, System.currentTimeMillis()); // For side effects.
295                 callback.sendSuccess(null);
296                 return;
297             }
298             if ("PushServiceAndroidGCM:DumpRegistration".equals(event)) {
299                 // In the future, this might be used to interrogate the Java Push Manager
300                 // registration state from JavaScript.
301                 callback.sendError("Not yet implemented!");
302                 return;
303             }
304             if ("PushServiceAndroidGCM:DumpSubscriptions".equals(event)) {
305                 try {
306                     final Map<String, PushSubscription> result = pushManager.allSubscriptionsForProfile(geckoProfile.getName());
307 
308                     final JSONObject json = new JSONObject();
309                     for (Map.Entry<String, PushSubscription> entry : result.entrySet()) {
310                         json.put(entry.getKey(), entry.getValue().toJSONObject());
311                     }
312                     callback.sendSuccess(json);
313                 } catch (JSONException e) {
314                     callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
315                 }
316                 return;
317             }
318             if ("PushServiceAndroidGCM:RegisterUserAgent".equals(event)) {
319                 try {
320                     pushManager.registerUserAgent(geckoProfile.getName(), System.currentTimeMillis()); // For side-effects.
321                     callback.sendSuccess(null);
322                 } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
323                     Log.e(LOG_TAG, "Got exception in " + event, e);
324                     callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
325                 }
326                 return;
327             }
328             if ("PushServiceAndroidGCM:UnregisterUserAgent".equals(event)) {
329                 // In the future, this might be used to tell the Java Push Manager to unregister
330                 // a User Agent entirely from JavaScript.  Right now, however, everything is
331                 // subscription based; there's no concept of unregistering all subscriptions
332                 // simultaneously.
333                 callback.sendError("Not yet implemented!");
334                 return;
335             }
336             if ("PushServiceAndroidGCM:SubscribeChannel".equals(event)) {
337                 final String service = SERVICE_FXA.equals(message.getString("service")) ?
338                                        SERVICE_FXA :
339                                        SERVICE_WEBPUSH;
340                 final JSONObject serviceData;
341                 final String appServerKey = message.getString("appServerKey");
342                 try {
343                     serviceData = new JSONObject();
344                     serviceData.put("profileName", geckoProfile.getName());
345                     serviceData.put("profilePath", geckoProfile.getDir().getAbsolutePath());
346                 } catch (JSONException e) {
347                     Log.e(LOG_TAG, "Got exception in " + event, e);
348                     callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
349                     return;
350                 }
351 
352                 final PushSubscription subscription;
353                 try {
354                     subscription = pushManager.subscribeChannel(geckoProfile.getName(), service, serviceData, appServerKey, System.currentTimeMillis());
355                 } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
356                     Log.e(LOG_TAG, "Got exception in " + event, e);
357                     callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
358                     return;
359                 }
360 
361                 final JSONObject json = new JSONObject();
362                 try {
363                     json.put("channelID", subscription.chid);
364                     json.put("endpoint", subscription.webpushEndpoint);
365                 } catch (JSONException e) {
366                     Log.e(LOG_TAG, "Got exception in " + event, e);
367                     callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
368                     return;
369                 }
370 
371                 Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.SERVICE, "dom-push-api");
372                 callback.sendSuccess(json);
373                 return;
374             }
375             if ("PushServiceAndroidGCM:UnsubscribeChannel".equals(event)) {
376                 final String channelID = message.getString("channelID");
377                 if (channelID == null) {
378                     callback.sendError("channelID must not be null in " + event);
379                     return;
380                 }
381 
382                 // Fire and forget.  See comments in the function itself.
383                 final PushSubscription pushSubscription = pushManager.unsubscribeChannel(channelID);
384                 if (pushSubscription != null) {
385                     Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.SERVICE, "dom-push-api");
386                     callback.sendSuccess(null);
387                     return;
388                 }
389 
390                 callback.sendError("Could not unsubscribe from channel: " + channelID);
391                 return;
392             }
393             if ("FxAccountsPush:ReceivedPushMessageToDecode:Response".equals(event)) {
394                 FxAccountPushHandler.handleFxAPushMessage(context, message);
395                 return;
396             }
397             if ("History:GetPrePathLastVisitedTimeMilliseconds".equals(event)) {
398                 if (callback == null) {
399                     Log.e(LOG_TAG, "callback must not be null in " + event);
400                     return;
401                 }
402                 final String prePath = message.getString("prePath");
403                 if (prePath == null) {
404                     callback.sendError("prePath must not be null in " + event);
405                     return;
406                 }
407                 // We're on a background thread, so we can be synchronous.
408                 final long millis = BrowserDB.from(geckoProfile).getPrePathLastVisitedTimeMilliseconds(
409                         context.getContentResolver(), prePath);
410                 callback.sendSuccess(millis);
411                 return;
412             }
413         } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
414             // TODO: improve this.  Can we find a point where the user is *definitely* interacting
415             // with the WebPush?  Perhaps we can show a dialog when interacting with the Push
416             // permissions, and then be more aggressive showing this notification when we have
417             // registrations and subscriptions that can't be advanced.
418             callback.sendError("To handle event [" + event + "], user interaction is needed to enable Google Play Services.");
419         }
420     }
421 
processComponentState(@onNull GeckoComponent component, boolean isReady)422     private void processComponentState(@NonNull GeckoComponent component, boolean isReady) {
423         if (component == GeckoComponent.FxAccountsPush) {
424             isReadyFxAccountsPush = isReady;
425 
426         } else if (component == GeckoComponent.PushServiceAndroidGCM) {
427             isReadyPushServiceAndroidGCM = isReady;
428         }
429 
430         // Send all pending messages to Gecko.
431         if (canSendPushMessagesToGecko()) {
432             sendPushMessagesToGecko(pendingPushMessages);
433             pendingPushMessages.clear();
434         }
435     }
436 
canSendPushMessagesToGecko()437     private boolean canSendPushMessagesToGecko() {
438         return isReadyFxAccountsPush && isReadyPushServiceAndroidGCM;
439     }
440 
sendPushMessagesToGecko(@onNull List<JSONObject> messages)441     private static void sendPushMessagesToGecko(@NonNull List<JSONObject> messages) {
442         for (JSONObject pushMessage : messages) {
443             final String profileName = pushMessage.optString("profileName", null);
444             final String profilePath = pushMessage.optString("profilePath", null);
445             final String service = pushMessage.optString("service", null);
446             if (profileName == null || profilePath == null ||
447                     !GeckoThread.canUseProfile(profileName, new File(profilePath))) {
448                 Log.e(LOG_TAG, "Mismatched profile for chid: " +
449                         pushMessage.optString("channelID") +
450                         "; ignoring dom/push message.");
451                 continue;
452             }
453             if (SERVICE_WEBPUSH.equals(service)) {
454                 sendMessageToGeckoService(pushMessage);
455             } else if (SERVICE_FXA.equals(service)) {
456                 sendMessageToDecodeToGeckoService(pushMessage);
457             }
458         }
459     }
460 }
461