Bug 1202767 - Bring Android notifications into Gaia r=fabrice

This commit is contained in:
sgiles 2015-10-30 15:10:31 +00:00
parent c74601b86c
commit e25884cddc
7 changed files with 500 additions and 9 deletions

View File

@ -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

View File

@ -121,6 +121,15 @@
</intent-filter>
</activity>
<service
android:name="org.mozilla.b2gdroid.NotificationObserver"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:process="org.mozilla.b2gdroid.NotificationObserver">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<service
android:exported="false"
android:name="org.mozilla.gecko.updater.UpdateService"

View File

@ -0,0 +1,60 @@
/* vim: set ts=4 sw=4 tw=80 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.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.util.Log;
import org.mozilla.gecko.GeckoAppShell;
import org.mozilla.gecko.GeckoEvent;
import org.mozilla.gecko.AppConstants;
/**
* A generic 'GeckoEvent' Intent receiver.
*
* A Context running in a different process to the main Context may wish to
* send a GeckoEvent. Using GeckoAppShell.sendEventToGecko from other
* processes will silently fail. Instead, broadcast an Intent containing the
* GeckoEvent data and the GeckoEventReceiver registered with the primary
* Context will forward the Event to Gecko.
*/
public class GeckoEventReceiver extends BroadcastReceiver {
private static final String LOGTAG = "B2GDroid:GeckoEventReceiver";
private static final String SEND_GECKO_EVENT = AppConstants.ANDROID_PACKAGE_NAME + ".SEND_GECKO_EVENT";
private static final String EXTRA_SUBJECT = "SUBJECT";
private static final String EXTRA_DATA = "DATA";
public static Intent createBroadcastEventIntent(String subject, String data) {
Intent intent = new Intent(SEND_GECKO_EVENT);
intent.putExtra(EXTRA_SUBJECT, subject);
intent.putExtra(EXTRA_DATA, data);
return intent;
}
public void registerWithContext(Context aContext) {
aContext.registerReceiver(this, new IntentFilter(SEND_GECKO_EVENT));
}
public void destroy(Context aContext) {
aContext.unregisterReceiver(this);
}
@Override
public void onReceive(Context aContext, Intent aIntent) {
String geckoEventSubject = aIntent.getStringExtra(EXTRA_SUBJECT);
String geckoEventJSON = aIntent.getStringExtra(EXTRA_DATA);
Log.i(LOGTAG, "onReceive(" + geckoEventSubject + ")");
GeckoEvent e = GeckoEvent.createBroadcastEvent(geckoEventSubject, geckoEventJSON);
GeckoAppShell.sendEventToGecko(e);
}
}

View File

@ -6,13 +6,8 @@ package org.mozilla.b2gdroid;
import java.util.Date;
import android.app.Activity;
import android.app.ActivityManager;
import android.app.KeyguardManager;
import android.app.KeyguardManager.KeyguardLock;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.location.Location;
import android.location.LocationListener;
@ -33,6 +28,7 @@ import org.mozilla.gecko.GeckoBatteryManager;
import org.mozilla.gecko.GeckoEvent;
import org.mozilla.gecko.GeckoThread;
import org.mozilla.gecko.IntentHelper;
import org.mozilla.gecko.AppNotificationClient;
import org.mozilla.gecko.updater.UpdateServiceHelper;
import org.mozilla.gecko.util.GeckoEventListener;
@ -48,6 +44,8 @@ public class Launcher extends FragmentActivity
private ScreenStateObserver mScreenStateObserver;
private Apps mApps;
private SettingsMapper mSettings;
private GeckoEventReceiver mGeckoEventReceiver;
private RemoteGeckoEventProxy mGeckoEventProxy;
private static final long kHomeRepeat = 2;
private static final long kHomeDelay = 500; // delay in ms to tap kHomeRepeat times.
@ -95,6 +93,7 @@ public class Launcher extends FragmentActivity
/** Initializes Gecko APIs */
private void initGecko() {
GeckoAppShell.setContextGetter(this);
GeckoAppShell.setNotificationClient(new AppNotificationClient(this));
GeckoBatteryManager.getInstance().start(this);
mContactService = new ContactService(EventDispatcher.getInstance(), this);
@ -122,6 +121,11 @@ public class Launcher extends FragmentActivity
initGecko();
mGeckoEventProxy = new RemoteGeckoEventProxy(this);
mGeckoEventReceiver = new GeckoEventReceiver();
mGeckoEventReceiver.registerWithContext(this);
GeckoAppShell.setGeckoInterface(new GeckoInterface(this));
UpdateServiceHelper.registerForUpdates(this);
@ -129,6 +133,12 @@ public class Launcher extends FragmentActivity
EventDispatcher.getInstance().registerGeckoThreadListener(this,
"Launcher:Ready");
// Register the RemoteGeckoEventProxy with the Notification Opened
// event, Notifications are handled in a different process as a
// service, so we need to forward them to the remote service
EventDispatcher.getInstance().registerGeckoThreadListener(mGeckoEventProxy,
"Android:NotificationOpened");
setContentView(R.layout.launcher);
mHomeCount = 0;
@ -141,6 +151,7 @@ public class Launcher extends FragmentActivity
super.onResume();
if (GeckoThread.isRunning()) {
hideSplashScreen();
NotificationObserver.registerForNativeNotifications(this);
}
}
@ -149,6 +160,10 @@ public class Launcher extends FragmentActivity
Log.w(LOGTAG, "onDestroy");
super.onDestroy();
IntentHelper.destroy();
mGeckoEventReceiver.destroy(this);
mGeckoEventReceiver = null;
mScreenStateObserver.destroy(this);
mScreenStateObserver = null;

View File

@ -0,0 +1,227 @@
/* 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 java.io.ByteArrayOutputStream;
import java.util.Map;
import java.util.HashMap;
import java.util.Arrays;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.res.Resources;
import android.app.PendingIntent;
import android.app.Notification;
import android.app.NotificationManager;
import android.service.notification.NotificationListenerService;
import android.service.notification.NotificationListenerService.RankingMap;
import android.service.notification.StatusBarNotification;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.util.Base64;
import android.util.Log;
import org.json.JSONObject;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.util.GeckoEventListener;
public class NotificationObserver extends NotificationListenerService
implements GeckoEventListener {
private static final String LOGTAG = "B2GDroid:NotificationObserver";
private static final String ACTION_REGISTER_FOR_NOTIFICATIONS = AppConstants.ANDROID_PACKAGE_NAME + ".ACTION_REGISTER_FOR_NOTIFICATIONS";
// Map a unique identifier consisting of the notification's
// tag and id. If the notification does not have a tag, the notification
// is keyed on the package name and id to attempt to namespace it
private Map<String, StatusBarNotification> mCurrentNotifications = new HashMap<String, StatusBarNotification>();
@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);
}
}

View File

@ -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);
}
}
}

View File

@ -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();