Saturday, April 5, 2014

Dynamic BroadcastReceiver Registration and Explicit Intents

Suppose your Android app posts a notification, and suppose you'd like to be informed when that notification is dismissed. There are a variety of good reasons you might want to do this--in my case, I wanted to avoid annoying a user who'd actively removed my notification by re-posting it the next time I had progress to report.

The Plan

Android provides a (relatively) straightforward mechanism for detecting when a notification you post is dismissed: you can attach a PendingIntent to its delete action. Seemed fairly simple; the plan was:
  1. Define a String called notification_dismissed to identify the dismiss action.
  2. Create a BroadcastReceiver and have its onReceive() react to notification_dismissed by setting an mIsNotificationVisible flag to false.
  3. Use PendingIntent.getBroadcast() to create a PendingIntent that will broadcast notification_dismissed when it's triggered. For security reasons, make this an explicit intent that will only deliver to my specific BroadcastReceiver class.
  4. Use Notfication.deleteIntent() to attach the PendingIntent to my notification as its delete action.
  5. Use Context.registerReceiver() to register an instance of my BroadcastReceiver with the system (registering through the manifest would have caused a new BroadcastReceiver to be instantiated for each notification, which would have made it tough to get to that mIsNotificationVisible flag without doing something ugly).

The Problem

The plan sounded good, but it had one small flaw--it didn't work at all. My BroadcastReceiver simply wasn't called when the notification was dismissed. After a few hours of pulling my hair out, I stumbled upon a solution more or less by accident. If I used an implicit intent (i.e one that is not addressed to a particular class) instead of an explicit one, everything worked fine. It seems that a dynamically-registered BroadcastReceiver cannot receive explicit intents (one mikro2nd describes this is a little more detail. If only I'd found that post about 5 hours earlier!). So far as I know, this is not documented anywhere.

I could have stopped there, used implicit intents, and everything probably would have been fine. But I really didn't like the security hole it would have created (imagine some malicious app catching your broadcasts and reacting to them by popping up a dialog that impersonates you).

The solution

They say all problems in computer science can be solved by adding another layer of abstraction, and so it proved here. My solution was to add a second BroadcastReceiver that would accept explicit intents and rebroadcast them internally as implicit intents using a LocalBroadcastManager.

Since the original intent is explicit, malicious apps cannot intercept or mimic it. Since the secondary implicit intent is sent through a LocalBroadcastManager, no one outside my app can see it. Best of both worlds. In more detail:
  1. Create a BroadcastReceiver named Rebroadcaster and register it in the manifest without an <intent-filter> (this means it can only receive explicit intents, which is what we want).
  2. Rebroadcaster creates a copy of each Intent it receives, calls setComponent(null) on the copy to make it implicit, and then sends the copy out through the LocalBroadcastManager.
  3. Address the notification's deleteIntent to Rebroadcaster.
  4. Register the final BroadcastReceiver with the LocalBroadcastManager instead of a Context.

tl;dr

Dynamically-registered BroadcastReceivers can't receive explicit intents. If you're not comfortable sending implicit broadcast PendingIntents outside your app, you can use a separate statically-registered BroadcastReceiver to accept explicit intents and rebroadcast them internally as implicit intents through the LocalBroadcastManager.

The code


Rebroadcaster:

public class Rebroadcaster extends BroadcastReceiver
{
  @Override public void onReceive(Context context, Intent intent)
  {
    LocalBroadcastManager manager = LocalBroadcastManager.getInstance(context);
    if (manager == null)
      return;
    Intent modifiedIntent = new Intent(intent);
    modifiedIntent.setComponent(null);
    manager.sendBroadcast(modifiedIntent);
  }
}

In the manifest:

<!-- No intent-filter means we can only receive explicit intents. -->
<receiver
  android:name="packageName.Rebroadcaster"
  android:exported="false"
  />

Creating the PendingIntent:

private PendingIntent getNotificationDismissedIntent(Context context)
{
  Intent intent = new Intent(context, Rebroadcaster.class);
  intent.setAction(context.getString(R.string.notification_dismissed));
  return PendingIntent.getBroadcast(
    context, NOTIFICATION_ID, intent, PendingIntent.FLAG_CANCEL_CURRENT);
}

private Notification buildNotification(Context context)
{
  return new NotificationCompat.Builder(context)
    // lots of configuration
    .setDeleteIntent(getNotificationDismissedIntent(context))
    .build();
}

Registering the real BroadcastReceiver:

final String deletedAction = context.getString(R.string.notification_dismissed);
LocalBroadcastManager.getInstance(context).registerReceiver(
  new BroadcastReceiver()
  {
    @Override public void onReceive(Context context, Intent intent)
    {
      if (intent.getAction() == null)
        return;
      if (!intent.getAction().equals(deletedAction))
        return;
      mIsNotificationVisible = false; // member of enclosing class
    }
  },
  new IntentFilter(deletedAction)
);

1 comment: