diff --git a/mobile/android/b2gdroid/app/Makefile.in b/mobile/android/b2gdroid/app/Makefile.in index a67d13692cf..c6075e1f909 100644 --- a/mobile/android/b2gdroid/app/Makefile.in +++ b/mobile/android/b2gdroid/app/Makefile.in @@ -9,6 +9,9 @@ JAVAFILES := \ src/main/java/org/mozilla/b2gdroid/Launcher.java \ src/main/java/org/mozilla/b2gdroid/ScreenStateObserver.java \ src/main/java/org/mozilla/b2gdroid/SettingsMapper.java \ + src/main/java/org/mozilla/b2gdroid/GeckoEventReceiver.java \ + src/main/java/org/mozilla/b2gdroid/NotificationObserver.java \ + src/main/java/org/mozilla/b2gdroid/RemoteGeckoEventProxy.java \ $(NULL) # The GeckoView consuming APK depends on the GeckoView JAR files. There are two diff --git a/mobile/android/b2gdroid/app/src/main/AndroidManifest.xml b/mobile/android/b2gdroid/app/src/main/AndroidManifest.xml index 5ace06c132d..6f3b10bb2b9 100644 --- a/mobile/android/b2gdroid/app/src/main/AndroidManifest.xml +++ b/mobile/android/b2gdroid/app/src/main/AndroidManifest.xml @@ -121,6 +121,15 @@ + + + + + + mCurrentNotifications = new HashMap(); + + @Override + public void onCreate() { + Log.i(LOGTAG, "onCreate()"); + super.onCreate(); + + RemoteGeckoEventProxy.registerRemoteGeckoThreadListener(this, this, "Android:NotificationOpened", "Android:NotificationClosed"); + } + + + @Override + public void onNotificationPosted(StatusBarNotification aNotification, RankingMap aRanking) { + Log.i(LOGTAG, "onNotificationPosted(aNotification, aRanking)"); + this.onNotificationPosted(aNotification); + } + + @Override + public void onNotificationPosted(StatusBarNotification aNotification) { + Log.i(LOGTAG, "onNotificationPosted(aNotification, aRanking)"); + + Notification notification = aNotification.getNotification(); + + CharSequence title = notification.extras.getCharSequence(Notification.EXTRA_TITLE); + CharSequence extraText = notification.extras.getCharSequence(Notification.EXTRA_TEXT); + + String notificationKey = getNotificationKey(aNotification); + + JSONObject notificationData = new JSONObject(); + JSONObject behaviors = new JSONObject(); + + try { + notificationData.put("_action", "post"); + notificationData.put("id", notificationKey); + notificationData.put("title", title.toString()); + notificationData.put("text", extraText.toString()); + notificationData.put("manifestURL", "android://" + aNotification.getPackageName().toLowerCase() + "/manifest.webapp"); + notificationData.put("icon", getBase64Icon(aNotification)); + notificationData.put("timestamp", aNotification.getPostTime()); + + if ((Notification.FLAG_ONGOING_EVENT & notification.flags) != 0) { + notificationData.put("noNotify", true); + } + + behaviors.put("vibrationPattern", Arrays.asList(notification.vibrate)); + + if ((Notification.FLAG_NO_CLEAR & notification.flags) != 0) { + behaviors.put("noclear", true); + } + + notificationData.put("mozbehavior", behaviors); + + } catch(Exception ex) { + Log.wtf(LOGTAG, "Error building android notification message " + ex.getMessage()); + return; + } + + mCurrentNotifications.put(notificationKey, aNotification); + sendBroadcast(GeckoEventReceiver.createBroadcastEventIntent("Android:Notification", notificationData.toString())); + } + + @Override + public void onNotificationRemoved(StatusBarNotification aNotification, RankingMap aRanking) { + Log.i(LOGTAG, "onNotifciationRemoved(aNotification, aRanking)"); + onNotificationRemoved(aNotification); + } + + @Override + public void onNotificationRemoved(StatusBarNotification aNotification) { + Log.i(LOGTAG, "onNotificationRemoved(aNotification)"); + + String notificationKey = getNotificationKey(aNotification); + removeNotificationFromGaia(notificationKey); + } + + private void removeNotificationFromGaia(String aNotificationKey) { + Log.i(LOGTAG, "removeNotificationFromGaia(aNotificationKey=" + aNotificationKey + ")"); + JSONObject notificationData = new JSONObject(); + + mCurrentNotifications.remove(aNotificationKey); + + try { + notificationData.put("_action", "remove"); + notificationData.put("id", aNotificationKey); + } catch(Exception ex) { + Log.wtf(LOGTAG, "Error building android notification message " + ex.getMessage()); + return; + } + + sendBroadcast(GeckoEventReceiver.createBroadcastEventIntent("Android:Notification", notificationData.toString())); + } + + private String getNotificationKey(StatusBarNotification aNotification) { + // API Level 20 has a 'getKey' method, however for backwards we + // implement this getNotificationKey + String notificationTag = aNotification.getTag(); + if (notificationTag == null) { + notificationTag = aNotification.getPackageName(); + } + + return notificationTag + "#" + Integer.toString(aNotification.getId()); + } + + private String getBase64Icon(StatusBarNotification aStatusBarNotification) { + Notification notification = aStatusBarNotification.getNotification(); + + PackageManager manager = getPackageManager(); + Resources notifyingPackageResources; + Bitmap bitmap = null; + + try { + notifyingPackageResources = manager.getResourcesForApplication(aStatusBarNotification.getPackageName()); + int resourceId = notification.icon; + bitmap = BitmapFactory.decodeResource(notifyingPackageResources, resourceId); + } catch(NameNotFoundException ex) { + Log.i(LOGTAG, "Could not find package name: " + ex.getMessage()); + } + + if (bitmap == null) { + // Try and get the icon directly from the notification + if (notification.largeIcon != null) { + bitmap = notification.largeIcon; + } else { + Log.i(LOGTAG, "No image found for notification"); + return ""; + } + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos); + return "data:image/png;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP); + } + + /* GeckoEventListener */ + public void handleMessage(String aEvent, JSONObject message) { + Log.i(LOGTAG, "handleMessage(aEvent=" + aEvent + ")"); + + String notificationId; + try { + notificationId = message.getString("id"); + } catch(Exception ex) { + Log.i(LOGTAG, "Malformed Android:NotificationOpened JSON - missing ID"); + return; + } + + StatusBarNotification sbNotification = mCurrentNotifications.get(notificationId); + + if (sbNotification == null) { + Log.w(LOGTAG, "No notification found for ID: " + notificationId); + return; + } + + if ("Android:NotificationOpened".equals(aEvent)) { + PendingIntent contentIntent = sbNotification.getNotification().contentIntent; + + try { + contentIntent.send(); + } catch(Exception ex) { + Log.i(LOGTAG, "Intent was cancelled"); + } + + removeNotificationFromGaia(notificationId); + return; + } else if("Android:NotificationClose".equals(aEvent)) { + NotificationManager notificationManager = (NotificationManager)getSystemService(NOTIFICATION_SERVICE); + + notificationManager.cancel(sbNotification.getTag(), sbNotification.getId()); + mCurrentNotifications.remove(notificationId); + } + } + + public static void registerForNativeNotifications(Context context) { + Intent intent = createNotificationIntent(context, ACTION_REGISTER_FOR_NOTIFICATIONS); + context.startService(intent); + } + + private static Intent createNotificationIntent(Context context, String action) { + return new Intent(action, /* URI */ null, context, NotificationObserver.class); + } +} diff --git a/mobile/android/b2gdroid/app/src/main/java/org/mozilla/b2gdroid/RemoteGeckoEventProxy.java b/mobile/android/b2gdroid/app/src/main/java/org/mozilla/b2gdroid/RemoteGeckoEventProxy.java new file mode 100644 index 00000000000..66675738d90 --- /dev/null +++ b/mobile/android/b2gdroid/app/src/main/java/org/mozilla/b2gdroid/RemoteGeckoEventProxy.java @@ -0,0 +1,96 @@ +/* vim: set ts=4 sw=4 tw=78 et : + * 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.b2gdroid; + +import android.util.Log; + +import android.content.Intent; +import android.content.IntentFilter; +import android.content.Context; +import android.content.BroadcastReceiver; + +import org.json.JSONObject; + +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.AppConstants; + +/** + * Proxy GeckoEvents from the Java UI Thread to other processes owned by this + * app - deferring processing to those processes. + * + * Usage: + * + * // In remote process `context` given GeckoEventListener `listener` + * RemoteGeckoEventProxy + * .registerRemoteGeckoThreadListener(context, listener, "Message:Message"); + * + * // In UI Thread (Main Activity `context`) + * RemoteGeckoEventProxy mGeckoEventProxy = new RemoteGeckoEventProxy(context); + * EventDispatcher + * .getInstance() + * .registerGeckoThreadListener(mGeckoEventProxy, "Message:Message"); + */ +class RemoteGeckoEventProxy implements GeckoEventListener { + + private static final String LOGTAG = "RemoteGeckoEventProxy"; + + private static final String RECEIVE_GECKO_EVENT = AppConstants.ANDROID_PACKAGE_NAME + ".RECEIVE_GECKO_EVENT"; + + private static final String EXTRA_DATA = "DATA"; + private static final String EXTRA_SUBJECT = "SUBJECT"; + + private Context mSenderContext; + + public static void registerRemoteGeckoThreadListener(Context aContext, GeckoEventListener mListener, String... aMessages) { + for (String message : aMessages) { + IntentFilter intentFilter = new IntentFilter(RECEIVE_GECKO_EVENT + "." + message); + aContext.registerReceiver(new GeckoEventProxyReceiver(mListener), intentFilter); + } + } + + public RemoteGeckoEventProxy(Context aSenderContext) { + mSenderContext = aSenderContext; + } + + public void handleMessage(String aEvent, JSONObject aMessage) { + Intent broadcastIntent = createBroadcastIntent(aEvent, aMessage.toString()); + mSenderContext.sendBroadcast(broadcastIntent); + } + + private static Intent createBroadcastIntent(String aEvent, String aMessage) { + Intent intent = new Intent(RECEIVE_GECKO_EVENT + "." + aEvent); + intent.putExtra(EXTRA_SUBJECT, aEvent); + intent.putExtra(EXTRA_DATA, aMessage); + return intent; + } + + static class GeckoEventProxyReceiver extends BroadcastReceiver { + + private GeckoEventListener mGeckoEventListener; + + public GeckoEventProxyReceiver(GeckoEventListener aEventListener) { + mGeckoEventListener = aEventListener; + } + + @Override + public void onReceive(Context aContext, Intent aIntent) { + String geckoEventSubject = aIntent.getStringExtra(EXTRA_SUBJECT); + String geckoEventJSON = aIntent.getStringExtra(EXTRA_DATA); + + // Unwrap the message data and invoke the GeckoEventListener + JSONObject obj; + try { + obj = new JSONObject(geckoEventJSON); + obj = obj.getJSONObject("value"); + } catch(Exception ex) { + Log.wtf(RemoteGeckoEventProxy.LOGTAG, "Could not decode JSON payload for message: " + geckoEventSubject); + return; + } + + mGeckoEventListener.handleMessage(geckoEventSubject, obj); + } + } +} diff --git a/mobile/android/b2gdroid/components/MessagesBridge.jsm b/mobile/android/b2gdroid/components/MessagesBridge.jsm index d0e4ff8c6d0..3572cdf5c33 100644 --- a/mobile/android/b2gdroid/components/MessagesBridge.jsm +++ b/mobile/android/b2gdroid/components/MessagesBridge.jsm @@ -10,6 +10,11 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/SystemAppProxy.jsm"); Cu.import("resource://gre/modules/Messaging.jsm"); +Cu.import("resource://gre/modules/AppsUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "appsService", + "@mozilla.org/AppsService;1", + "nsIAppsService"); XPCOMUtils.defineLazyServiceGetter(this, "settings", "@mozilla.org/settingsService;1", @@ -36,8 +41,11 @@ this.MessagesBridge = { Services.obs.addObserver(this.onAndroidMessage, "Android:Launcher", false); Services.obs.addObserver(this.onAndroidSetting, "Android:Setting", false); Services.obs.addObserver(this.onSettingChange, "mozsettings-changed", false); + Services.obs.addObserver(this.onAndroidNotification, "Android:Notification", false); Services.obs.addObserver(this, "xpcom-shutdown", false); + SystemAppProxy.addEventListener("mozContentNotificationEvent", this); + // Send a request to get the device's IMEI. Messaging.sendRequestForResult({ type: "Android:GetIMEI" }) .then(aData => { @@ -47,6 +55,38 @@ this.MessagesBridge = { }); }, + handleEvent: function(evt) { + let detail = evt.detail; + + switch(detail.type) { + case "desktop-notification-click": + debug("Sending Android:NotificationOpened"); + Messaging.sendRequest({ type: "Android:NotificationOpened", value: { id: detail.id }}); + break; + case "desktop-notification-close": + // On receipt of a notification close, send the id to the Android layer + // so it can be removed from the Android NotificationManager. + debug("Sending Android:NotificationClosed"); + Messaging.sendRequest({ type: "Android:NotificationClosed", value: { id: detail.id }}); + break; + } + }, + + onAndroidNotification: function(aSubject, aTopic, aData) { + let data = JSON.parse(aData); + debug("Got android notification: " + data._action); + switch(data._action) { + case "post": + debug("showNotification(id=" + data.id + ")"); + showNotification(data); + break; + case "remove": + debug("removeNotification(id=" + data.id + ")"); + removeNotification(data); + break; + } + }, + onAndroidMessage: function(aSubject, aTopic, aData) { let data = JSON.parse(aData); debug(`Got Android:Launcher message ${data.action}`); @@ -122,9 +162,50 @@ this.MessagesBridge = { Services.obs.removeObserver(this.onAndroidMessage, "Android:Launcher"); Services.obs.removeObserver(this.onAndroidSetting, "Android:Setting"); Services.obs.removeObserver(this.onSettingChange, "mozsettings-changed"); + Services.obs.removeObserver(this.onAndroidNotification, "Android:Notification"); Services.obs.removeObserver(this, "xpcom-shutdown"); } } } +function removeNotification(aDetail) { + // Remove the notification + SystemAppProxy._sendCustomEvent("mozChromeNotificationEvent", { + type: "desktop-notification-close", + id: aDetail.id + }); +} + +function showNotification(aDetail) { + const manifestURL = aDetail.manifestURL; + + function send(appName) { + aDetail.type = "desktop-notification"; + aDetail.appName = appName; + + SystemAppProxy._sendCustomEvent("mozChromeNotificationEvent", aDetail); + } + + if (!manifestURL|| !manifestURL.length) { + send(null); + return; + } + + // If we have a manifest URL, get the app title from the manifest + // to prevent spoofing. + appsService.getManifestFor(manifestURL).then((manifest) => { + let app = appsService.getAppByManifestURL(manifestURL); + + // Sometimes an android notification may be created by a service rather + // than an app. In this case, don't use the appName. + if (!app) { + send(null); + return; + } + + let helper = new ManifestHelper(manifest, app.origin, manifestURL); + send(helper.name); + }); +} + this.MessagesBridge.init();