Manas Tungare

Make new features discoverable without annoying your users

Sep 16, 2016

Developers are faced with a choice: we need to make our users aware of new features that have been built since the last release. But we don’t want to get in the way of users trying to use our apps, so it has to be done without breaking their flow.

Not every version is a major release, so we also don’t want to show a large in-your-face message every single time the app is updated.

Some features are important enough that if existing users found out about them, they might start using the app more. But if they’ve abandoned the app (installed, but not using it), they won’t even find out about the new features if they never opened the app.

This is where having a well-defined version structure comes into play. If you haven’t yet read my post about managing your Android build’s major, minor and patch versions individually, now is a good time to do that! This post relies on that structured versioning approach.

Here’s how my app does it. People seem to like it, we’ve heard zero complaints about any of it, and we see bumps in usage when highlighting new features.

Here’s the code:

Android can tell us when our app gets updated. This is the perfect callback to hook into.

public class PackageUpdateReceiver extends BroadcastReceiver {
  @Override
  public void onReceive(Context context, Intent received) {
    if (Intent.ACTION_MY_PACKAGE_REPLACED.equals(
        receivedIntent.getAction())) {
      onUpgraded(context, BuildConfig.VERSION_CODE);
    }
  }
}

Make sure your AndroidManifest.xml is set up for receiving these broadcasts, by subscribing to the system standard broadcasts for android.intent.action.MY_PACKAGE_REPLACED.

<receiver android:name=".PackageUpdateReceiver">
  <intent-filter android:priority="1000">
    <action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
  </intent-filter>
</receiver>

The first thing we do in onUpgraded is to check if we should show a notification.

private void onUpgraded(Context context, int versionCode) {
  if (shouldNotifyOnUpgraded(context, versionCode)) {
    notifyOnUpgraded(context, versionCode);
  }
  maybeShowSnackbarOnNextAppLaunch(context, versionCode);
  // This must be the last line in the method; previous methods 
  // use this value to determine the “old” version installed.
  Prefs.edit(context).putInt(Prefs.INSTALLED_VERSION,
      versionCode).apply();
}

showNotifyOnUpgraded checks if the major version of the app has changed from the last one recorded. Pretty straightforward.

/**
 * Notify the user if and only if the major version (first component
 * of major.minor.patch version) is strictly greater than the 
 * current version. This ensures that we notify the user no more
 * than once every major version.
 */
private boolean shouldNotifyOnUpgraded(Context context, 
                                       int versionCode) {
  // If user has opted out of notifications, never notify.
  if (!Prefs.get(context).getBoolean(
      Prefs.NOTIFY_ON_UPGRADES, true /* defaultValue */)) {
    return false;
  }
  int lastMajorVersion = 
      Prefs.get(context).getInt(Prefs.INSTALLED_VERSION, 0) / 10000;
  int currentMajorVersion = versionCode / 10000;
  return currentMajorVersion > lastMajorVersion;
}

Now comes the actual notification. In the interest of clarity, we leave out the bits where we use an image loader to grab a bitmap off the main thread, and highlight only the call to the NotificationManager.

private void notifyOnUpgraded(Context context, int versionCode) {
  PendingIntent openedPendingIntent = PendingIntent.getBroadcast(
      context, 0, new Intent(
          Constants.Actions.ACTION_UPGRADE_NOTIFICATION_OPENED),
          PendingIntent.FLAG_UPDATE_CURRENT);
  PendingIntent optOutPendingIntent = PendingIntent.getBroadcast(
      context, 0, new Intent(
          Constants.Actions.ACTION_UPGRADE_NOTIFICATION_OPT_OUT),
          PendingIntent.FLAG_UPDATE_CURRENT);
  NotificationCompat.Builder upgradeNotification = 
     new NotificationCompat.Builder(context)
          .setSmallIcon(R.drawable.ic_whatshot_grey600_24dp)
          .setLargeIcon(bitmap)  // Code left out for clarity.
          .setAutoCancel(true)
          .setContentTitle(context.getString(R.string.new_features))
          .setContentText(context.getString(R.string.app_version))
          .setContentIntent(openedPendingIntent)
          .setPriority(Notification.PRIORITY_DEFAULT)
          .addAction(new NotificationCompat.Action(
              R.drawable.ic_whatshot_grey600_24dp,
              context.getString(R.string.whats_new),
              openedPendingIntent))
          .addAction(new NotificationCompat.Action(
              R.drawable.ic_remove_circle_outline_grey600_24dp,
              context.getString(R.string.dont_show), 
              optOutPendingIntent));
  NotificationManager notificationManager = (NotificationManager)
      context.getSystemService(Context.NOTIFICATION_SERVICE);
  notificationManager.notify(UPGRADE_NOTIFICATION_ID,
      upgradeNotification.build());
}
@Override
public void onReceive(Context context, Intent receivedIntent) {
  if (Intent.ACTION_MY_PACKAGE_REPLACED.equals(
      receivedIntent.getAction())) {
    onUpgraded(context, BuildConfig.VERSION_CODE);

  } else if (Constants.Actions.ACTION_UPGRADE_NOTIFICATION_OPENED
      .equals(receivedIntent.getAction())) {
    cancelNotification(context);

    Intent showWhatsNewIntent = new Intent(
        context, MainActivity.class);
    showWhatsNewIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    showWhatsNewIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
    showWhatsNewIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
    context.startActivity(showWhatsNewIntent);

  } else if (Constants.Actions.ACTION_UPGRADE_NOTIFICATION_OPT_OUT
      .equals(receivedIntent.getAction())) {
    cancelNotification(context);

    Prefs.edit(context).putBoolean(
        Prefs.NOTIFY_ON_UPGRADES, false).apply();
    Toast.makeText(context, 
        R.string.upgrade_notification_turned_off, Toast.LENGTH_LONG)
        .show();

    Intent showSettingsIntent = new Intent(
        Constants.Actions.ACTION_SHOW_SETTINGS, null /* uri */,
        context, MainActivity.class);
    showSettingsIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    context.startActivity(showSettingsIntent);
  }
}

Finally, for changes to the major version or the minor version, we will show a snackbar inside the app. For that, we don’t do any actual processing inside PackageUpdateReceiver; we simply set a SharedPreference that the main Activity (or Fragment) reads. If true, it shows a snackbar and then clears the bit.

/**
 * Set up a SharedPreference for whether to show a “What’s New”
 * snackbar the next time the app is launched. This is done for all
 * minor versions, which, by definition, have a user-noticeable
 * feature. This is not shown for patch versions (e.g. going from
 * 1.2.7 to 1.2.8) because patch releases are bug-fix-only or minor
 * enhancements only.
 */
private void maybeShowSnackbarOnNextAppLaunch(Context context, 
                                              int versionCode) {
  int lastMajorMinorVersion =
      Prefs.get(context).getInt(Prefs.INSTALLED_VERSION, 0) / 100;
  int currentMajorMinorVersion = versionCode / 100;
  if (currentMajorMinorVersion > lastMajorMinorVersion) {
    Prefs.edit(context).putBoolean(
        Prefs.SHOW_WHATS_NEW_ON_NEXT_LAUNCH, true).commit();
  }
}

Throughout this example, Prefs is a singleton convenience wrapper for the SharedPreferences class. This is what it looks like:

public class Prefs {
  private static SharedPreferences preferences;

  private Prefs() {
  }

  /**
   * @return A read-only instance of this app’s default
   * {@code SharedPreferences}.
   */
  public static SharedPreferences get(Context context) {
    if (preferences == null) {
      preferences =
          PreferenceManager.getDefaultSharedPreferences(context);
    }
    return preferences;
  }

  public static SharedPreferences.Editor edit(Context context) {
    return get(context).edit();
  }
}

Related Posts