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