/** * AlarmService.java * Copyright (C)2015 Nicholas Killewald * * This file is distributed under the terms of the BSD license. * The source package should have a LICENCE file at the toplevel. */ package net.exclaimindustries.geohashdroid.services; import android.app.AlarmManager; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.BitmapFactory; import android.graphics.Color; import android.net.ConnectivityManager; import android.os.Build; import android.os.Bundle; import android.preference.PreferenceManager; import android.support.annotation.IdRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.util.Log; import com.commonsware.cwac.wakeful.WakefulIntentService; import net.exclaimindustries.geohashdroid.R; import net.exclaimindustries.geohashdroid.activities.CentralMap; import net.exclaimindustries.geohashdroid.util.GHDConstants; import net.exclaimindustries.geohashdroid.util.Graticule; import net.exclaimindustries.geohashdroid.util.HashBuilder; import net.exclaimindustries.geohashdroid.util.Info; import net.exclaimindustries.geohashdroid.util.KnownLocation; import net.exclaimindustries.geohashdroid.util.UnitConverter; import net.exclaimindustries.tools.AndroidUtil; import java.text.DateFormat; import java.util.Calendar; import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.TimeZone; /** * <p> * <code>AlarmService</code> is a background service that retrieves the current stock * value around 9:30am ET (that is, a reasonable time after the opening of the * New York Stock Exchange, at which time the DJIA opening value is known). * It makes requests to {@link StockService}, which then stores the result away * in the cache so that later instances of hashing will have that data available * right away. * </p> * * <p> * This WILL try to start itself at boot time (assuming we get the boot intent). * </p> * * @author Nicholas Killewald * */ public class AlarmService extends WakefulIntentService { private static final String DEBUG_TAG = "AlarmService"; private AlarmManager mAlarmManager; private NotificationManager mNotificationManager; private Notification.Builder mNotificationBuilder; /** * Broadcast intent for the alarm that tells StockService that it's time to * go fetch a stock. At that time, it'll retrieve stock data for "today" * and "yesterday". In this case, "today" and "yesterday" are both relative * to when stock data is expected to exist for the actual "today"; for * instance, if this is called on a Saturday, "today" will be Friday (the * NYSE isn't open on Saturday, so Friday's open value is used) and * "yesterday" will also be Friday (both 30W and non-30W users get the same * hash data on Saturdays and Sundays). */ private static final String STOCK_ALARM = "net.exclaimindustries.geohashdroid.STOCK_ALARM"; /** * Broadcast intent for the alarm that tells StockService to try again on * a failed check due to the stock not being posted yet. In practice, the * resulting action will be the same as STOCK_ALARM (cache the stocks). * This is needed because otherwise it'd be considered the same intent, * meaning the single-shot alarm would cancel the first one. * * Do note, this intent should NOT be scheduled to be repeating. */ private static final String STOCK_ALARM_RETRY = "net.exclaimindustries.geohashdroid.STOCK_ALARM_RETRY"; /** * Intent sent when the network's come back up. This tells the service to * shut off the receiver and otherwise behave as if it were a STOCK_ALARM. */ private static final String STOCK_ALARM_NETWORK_BACK = "net.exclaimindustries.geohashdroid.STOCK_ALARM_NETWORK_BACK"; /** * Directed intent to tell StockService to set the alarms. */ public static final String STOCK_ALARM_ON = "net.exclaimindustries.geohashdroid.STOCK_ALARM_ON"; /** * Directed intent to tell StockService to cancel the alarms. */ public static final String STOCK_ALARM_OFF = "net.exclaimindustries.geohashdroid.STOCK_ALARM_OFF"; /** * Directed intent to tell CentralMap to go directly to this Info. */ public static final String START_INFO = "net.exclaimindustries.geohashdroid.START_INFO"; /** * Directed intent to tell CentralMap to go directly to this Info, and it's * also a globalhash, and this helps make it different enough from the other * intent that isn't a globalhash such that PendingIntent won't overwrite * one with the other. */ public static final String START_INFO_GLOBAL = "net.exclaimindustries.geohashdroid.START_INFO_GLOBAL"; /** * This receiver listens for network connectivity changes in case we ran * into a problem with network connectivity and wanted to know if that * changed. */ public static class NetworkReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { if(intent.getAction().equals(ConnectivityManager.CONNECTIVITY_ACTION)) { Log.d(DEBUG_TAG, "Network status update!"); if(AndroidUtil.isConnected(context)) { Log.d(DEBUG_TAG, "The network is back up!"); // NETWORK'D!!! Intent i = new Intent(context, AlarmService.class); i.setAction(STOCK_ALARM_NETWORK_BACK); WakefulIntentService.sendWakefulWork(context, i); } } } } /** * This wakes up the service when the party alarm starts. */ public static class StockAlarmReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { Log.d(DEBUG_TAG, "STOCK ALARM!!! Action is " + intent.getAction()); // Fire off the Intent to start up the service. That'll handle all // of whatever we need handled. Intent i = new Intent(context, AlarmService.class); i.setAction(intent.getAction()); WakefulIntentService.sendWakefulWork(context, i); } } /** * This listens for any update from StockService, throwing out anything that * isn't related to the alarm. */ public static class StockReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { // Check the Intent for the alarm flag. We'll just straight give up // if it's not an alarm, since we don't really care. Bundle stuff = intent.getBundleExtra(StockService.EXTRA_STUFF); int flags = 0; if(stuff != null) { flags = stuff.getInt(StockService.EXTRA_REQUEST_FLAGS, 0); } if((flags & StockService.FLAG_ALARM) != 0) { Log.d(DEBUG_TAG, "StockService returned with an alarming response!"); // It's ours! Send it to the wakeful part! intent.setClass(context, AlarmService.class); WakefulIntentService.sendWakefulWork(context, intent); } } } /** * When bootup happens, this makes sure AlarmService is ready to go if the * user's got that set up. */ public static class BootReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { if(intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) { // It's boot time! We might need to flip on the party alarm! Log.i(DEBUG_TAG, "Gooooooood morning, Geohashland! It's boot time in " + TimeZone.getDefault().getDisplayName() + "!"); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); if(prefs.getBoolean(GHDConstants.PREF_STOCK_ALARM, false)) { // Set the alarm! Log.i(DEBUG_TAG, "The stock alarm is now being started..."); Intent i = new Intent(context, AlarmService.class); i.setAction(AlarmService.STOCK_ALARM_ON); WakefulIntentService.sendWakefulWork(context, i); } else { Log.i(DEBUG_TAG, "The stock alarm is off, nothing's being started."); } } } } /** * This makes a 9:30am ET Calendar for today's date. Note that even if a * Calendar is supplied, what will be returned will be in America/New_York, * using the date it is in New York right now. * * @param source if not null, use this as the base, rather than build up a * new Calendar from scratch * @return a new Calendar for 9:30am ET for today's (or the supplied) date */ private Calendar makeNineThirty(@Nullable Calendar source) { Calendar base; if(source == null) { base = Calendar.getInstance(TimeZone.getTimeZone("America/New_York")); } else { base = (Calendar)source.clone(); base.setTimeZone(TimeZone.getTimeZone("America/New_York")); } base.set(Calendar.HOUR_OF_DAY, 9); base.set(Calendar.MINUTE, 30); base.set(Calendar.SECOND, 0); base.set(Calendar.MILLISECOND, 0); return base; } /** * Makes a new Calendar that represents the most recent probable date that * a stock would exist. It does so by comparing the current time to 9:30am * ET of the same day. If it's before 9:30am (and would thus be before we * can confidently say the NYSE has opened and a value reported), this will * rewind it by one day. If it's after 9:30am, the date will remain the * same. Note that the only important part of this is the date; the actual * time and time zone of the returned value are not guaranteed, though * chances are it'll be in the same time zone as what is given (or the * default time zone if not given). * * This implicitly assumes that source is today, if given. This won't * return an accurate date if, say, source is next week. * * @param source if not null, use this as the base, rather than whatever * the system considers the current time. * @return a new Calendar whose date is the most recent date a stock is * likely to exist. */ @NonNull private Calendar getMostRecentStockDate(@Nullable Calendar source) { Calendar base; if(source == null) { base = Calendar.getInstance(); } else { base = (Calendar)source.clone(); } // First, get 9:30 for today. Calendar nineThirty = makeNineThirty(base); // Then, compare it to the base. if(base.before(nineThirty)) { // It's before 9:30am! Rewind! base.add(Calendar.DAY_OF_MONTH, -1); } // And that should be that! return base; } public AlarmService() { super("AlarmService"); } private void showNotification(@NonNull Calendar date) { // The notification in this case just says when there's an active // network transaction going. We don't need to bug the user that we're // waiting for a network connection, as chances are, the user's also // waiting for one, and doesn't need us reminding them of this fact. mNotificationBuilder.setContentText( getString(R.string.notification_detail, DateFormat .getDateInstance(DateFormat.MEDIUM) .format(date.getTime()))); mNotificationManager.notify(R.id.alarm_notification, mNotificationBuilder.build()); } private void clearNotification() { mNotificationManager.cancel(R.id.alarm_notification); } private void snooze() { Calendar cal = Calendar.getInstance(); cal.add(Calendar.MINUTE, 30); Intent alarmIntent = new Intent(this, StockAlarmReceiver.class); alarmIntent.setAction(STOCK_ALARM_RETRY); // Even if the user's added us to the whitelist, we won't be able to use // the plain set call in Marshmallow or higher. Not with Doze to worry // about. if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { mAlarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, cal.getTimeInMillis(), PendingIntent.getBroadcast(this, 0, alarmIntent, 0)); } else { mAlarmManager.set(AlarmManager.RTC_WAKEUP, cal.getTimeInMillis(), PendingIntent.getBroadcast(this, 0, alarmIntent, 0)); } } /** * <p> * Sets up the next stock alarm (9:30am ET). We need to do this rather than * use setRepeating because Doze ruined that for us. * </p> * * <p> * This is a convenience method to always pass false to {@link #setNextAlarm(boolean)}. * That is, this may set an alarm for later today if it isn't 9:30am ET yet. * </p> * * @see #setNextAlarm(boolean) */ private void setNextAlarm() { setNextAlarm(false); } /** * Sets up the next stock alarm (9:30am ET). We need to do this rather than * use setRepeating because Doze ruined that for us. * * @param definitelyTomorrow true to always set the alarm for tomorrow, even if it's before 9:30am ET */ private void setNextAlarm(boolean definitelyTomorrow) { // We're aiming at 9:30am ET (with any applicable DST adjustments). The // NYSE opens at 9:00am ET, but in the interests of possible clock // discrepancies and such (not to mention any delays in the stock // reporting sites being updated), we'll wait the extra half hour. The // alarm should be the NEXT available 9:30am ET. If the user wants to // take a chance and get a stock value closer to 9:00am ET than that, // well, they can do it themselves. Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("America/New_York")); Calendar alarmTime = makeNineThirty(cal); if(definitelyTomorrow || alarmTime.before(cal)) { alarmTime.add(Calendar.DAY_OF_MONTH, 1); } Intent alarmIntent = new Intent(STOCK_ALARM); alarmIntent.setClass(this, StockAlarmReceiver.class); Log.d(DEBUG_TAG, "Setting a wakeup alarm for " + alarmTime.getTime().toString()); // Because there's no Doze-friendly version of setRepeating (grr), we // have to re-set an allow-idle alarm every time. if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { mAlarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, alarmTime.getTimeInMillis(), PendingIntent.getBroadcast(this, 0, alarmIntent, 0)); } else { mAlarmManager.set(AlarmManager.RTC_WAKEUP, alarmTime.getTimeInMillis(), PendingIntent.getBroadcast(this, 0, alarmIntent, 0)); } } private void sendRequest(@NonNull Graticule g) { // The Graticule will be one of the dummies, as all we really care about // is if it's 30W or not. And we don't really care about it THAT much, // just enough to put the right string in the notification. Otherwise, // StockService works it out. Calendar cal = getMostRecentStockDate(null); Intent request = new Intent(this, StockService.class); request.setAction(StockService.ACTION_STOCK_REQUEST) .putExtra(StockService.EXTRA_GRATICULE, g) .putExtra(StockService.EXTRA_DATE, cal) .putExtra(StockService.EXTRA_REQUEST_ID, cal.getTimeInMillis() / 1000) .putExtra(StockService.EXTRA_REQUEST_FLAGS, StockService.FLAG_ALARM); // The notification goes up first. showNotification(Info.makeAdjustedCalendar(cal, g)); // THEN we send the request. WakefulIntentService.sendWakefulWork(this, request); } @Override public void onCreate() { super.onCreate(); // Init these now, at create time. The service MIGHT not die between // calls, after all. Maybe. mAlarmManager = (AlarmManager)getSystemService(Context.ALARM_SERVICE); mNotificationManager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE); // Ready the notification! The detail text will be set by date, of // course. mNotificationBuilder = new Notification.Builder(this) .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher)) .setSmallIcon(R.drawable.ic_stat_file_file_download) .setContentTitle(getString(R.string.notification_title)); // Oh, and if we're in Lollipop, we can go ahead and make this a public // Notification. It's not really sensitive. if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) mNotificationBuilder.setVisibility(Notification.VISIBILITY_PUBLIC); } @Override protected void doWakefulWork(Intent intent) { if(intent.getAction().equals(STOCK_ALARM_OFF)) { // We've been told to stop all alarms! Log.d(DEBUG_TAG, "Got STOCK_ALARM_OFF!"); mAlarmManager.cancel(PendingIntent.getBroadcast(this, 0, new Intent(STOCK_ALARM).setClass(this, StockAlarmReceiver.class), 0)); mAlarmManager.cancel(PendingIntent.getBroadcast(this, 0, new Intent(STOCK_ALARM_RETRY).setClass(this, StockAlarmReceiver.class), 0)); AndroidUtil.setPackageComponentEnabled(this, NetworkReceiver.class, false); clearNotification(); } else if(intent.getAction().equals(STOCK_ALARM_ON)) { Log.d(DEBUG_TAG, "Got STOCK_ALARM_ON!"); // At init time, set the alarm. setNextAlarm(); // AlarmManager sends out broadcasts, and the receiver we've got // will wake the service back up, so we can stop everything right // now. } else if(intent.getAction().equals(STOCK_ALARM) || intent.getAction().equals(STOCK_ALARM_RETRY) || intent.getAction().equals(STOCK_ALARM_NETWORK_BACK) || intent.getAction().equals(StockService.ACTION_STOCK_RESULT)) { // Aha! NOW we've got something! Log.d(DEBUG_TAG, "AlarmService has business to attend to!"); // If we've been told the network just came back, we can shut off // the network receiver. If we're still in trouble network-wise, // it'll go right back on when we check in a second. if(intent.getAction().equals(STOCK_ALARM_NETWORK_BACK)) { Log.d(DEBUG_TAG, "The network came back! Yay! Disabling the network status receiver..."); AndroidUtil.setPackageComponentEnabled(this, NetworkReceiver.class, false); } // If we just got the stock alarm, we need to reschedule right away. if(intent.getAction().equals(STOCK_ALARM)) { Log.d(DEBUG_TAG, "Rescheduling next STOCK_ALARM..."); setNextAlarm(true); } // If we got the REAL stock alarm while still waiting on the RETRY // alarm (i.e. the server kept reporting the stock wasn't posted all // day until the next 9:30), we should stop the retry alarm. It'll // get set back up if the stock is STILL unavailable, and by // shutting it down here, we preferably avoid acting on two alarms // at the same time. mAlarmManager.cancel(PendingIntent.getBroadcast(this, 0, new Intent(STOCK_ALARM_RETRY).setClass(this, StockAlarmReceiver.class), 0)); // StockService takes care of all the network connectivity checks // and other things that the alarm-checking StockService used to // take care of. It'll also tell us if the stock hasn't been // posted just yet. So, we can count on that for error checking. if(intent.getAction().equals(StockService.ACTION_STOCK_RESULT)) { Log.d(DEBUG_TAG, "Just got a stock result!"); Bundle bun = intent.getBundleExtra(StockService.EXTRA_STUFF); bun.setClassLoader(getClassLoader()); int result = bun.getInt(StockService.EXTRA_RESPONSE_CODE, StockService.RESPONSE_NOT_POSTED_YET); Graticule g = bun.getParcelable(StockService.EXTRA_GRATICULE); if(result == StockService.RESPONSE_NO_CONNECTION) { // No connection means we just set up the receiver and wait. // And wait. And wait. Log.d(DEBUG_TAG, "No network connection available, waiting until we get one..."); AndroidUtil.setPackageComponentEnabled(this, NetworkReceiver.class, true); clearNotification(); return; } if(result == StockService.RESPONSE_NOT_POSTED_YET) { // Not posted yet means we hit the snooze and try again in a // half hour or so. Good night! Log.d(DEBUG_TAG, "Stock wasn't posted yet, snoozing for a half hour..."); snooze(); clearNotification(); return; } if(result == StockService.RESPONSE_NETWORK_ERROR) { // A network error that ISN'T "no connection" is usually // really bad. But, with Doze in effect, that might mean // something weird with how it denies us network access, so // let's just snooze for now. Log.w(DEBUG_TAG, "Network reported an error, snoozing for a half hour..."); snooze(); clearNotification(); return; } if(result == StockService.RESPONSE_OKAY) { // An okay response means the Graticule IS good. If not, // fix StockService. if(g == null) { Log.w(DEBUG_TAG, "g is somehow null in AlarmService?"); clearNotification(); } else if(g.uses30WRule()) { // If the response we just checked for was a 30W one and // it came back okay, then we fire off a check for the // non-30W one. Log.d(DEBUG_TAG, "That was the 30W response, going up to non-30W..."); sendRequest(GHDConstants.DUMMY_TODAY); } else { // If, however, we got the non-30W back, then our job is // done! Yay! Log.d(DEBUG_TAG, "The 30W response! We're done!"); clearNotification(); // And since it's done, we can go off to the part where // we deal with KnownLocations! doKnownLocations(); } } } else { // If it's NOT a result, that means we're starting a new check // at a 30W hash for some reason. Doesn't matter what reason. // We just need to do it. Log.d(DEBUG_TAG, "That wasn't a result, so asking for a 30W..."); sendRequest(GHDConstants.DUMMY_YESTERDAY); } } else { // Stop doing this! Log.w(DEBUG_TAG, "Told to start on unknown action " + intent.getAction() + ", ignoring..."); } } /** * Convenient container for all the data we need for matches. */ private static class KnownLocationMatchData implements Comparable<KnownLocationMatchData> { public final KnownLocation knownLocation; public final Info bestInfo; public final double distance; public KnownLocationMatchData(@NonNull KnownLocation kl, @NonNull Info info, double dist) { knownLocation = kl; bestInfo = info; distance = dist; } @Override public int compareTo(@NonNull KnownLocationMatchData another) { // We want to sort this by how close it is. The LOWEST number // should go first (that's the closest one). I hope I got the // order right. if(distance < another.distance) return -1; if(distance > another.distance) return 1; return 0; } } private void doKnownLocations() { // First things first, clear out any old notifications. If those are // still around, they're from previous days, so they're no longer valid. mNotificationManager.cancel(R.id.alarm_known_location); mNotificationManager.cancel(R.id.alarm_known_location_global); String notifyPref = PreferenceManager.getDefaultSharedPreferences(this).getString(GHDConstants.PREF_KNOWN_NOTIFICATION, GHDConstants.PREFVAL_KNOWN_NOTIFICATION_ONLY_ONCE); // If the user doesn't want notifications, we can skip the rest of this. if(notifyPref.equals(GHDConstants.PREFVAL_KNOWN_NOTIFICATION_NEVER)) return; List<KnownLocation> locations = KnownLocation.getAllKnownLocations(this); // If there are no KnownLocations, give up now. if(locations.isEmpty()) return; List<KnownLocationMatchData> matched = new LinkedList<>(); List<KnownLocationMatchData> matchedGlobal = new LinkedList<>(); Calendar today = Calendar.getInstance(); Info global = HashBuilder.getStoredInfo(this, today, null); for(KnownLocation kl : locations) { // Every KnownLocation has a method to do this. Maybe it's a wee // bit inefficient and inelegant, but it does the job. Info best = kl.getClosestInfo(this, today); if(kl.isCloseEnough(best.getFinalDestinationLatLng())) { KnownLocationMatchData data = new KnownLocationMatchData(kl, best, kl.getDistanceFrom(best)); matched.add(data); } // The Globalhash will be handled as a separate notification, // because frankly, that's sort of special. if(global != null && kl.isCloseEnough(global.getFinalDestinationLatLng())) { KnownLocationMatchData data = new KnownLocationMatchData(kl, global, kl.getDistanceFrom(global)); matchedGlobal.add(data); } } // Did we get anything? Anything AT ALL? if(!matched.isEmpty()) { // So now we have a list of what matched. From there, let's sort // out what notifications need to go up, if any. There's a // preference for this sort of thing, and we already checked it // earlier. switch(notifyPref) { case GHDConstants.PREFVAL_KNOWN_NOTIFICATION_ONLY_ONCE: // Only once. That is, classic style. launchNotification(matched, START_INFO, R.id.alarm_known_location, R.string.known_locations_alarm_title); break; case GHDConstants.PREFVAL_KNOWN_NOTIFICATION_PER_GRATICULE: // Once per graticule. Well, now we need to sort these out by // graticule. // TODO: Do that! break; case GHDConstants.PREFVAL_KNOWN_NOTIFICATION_PER_LOCATION: // Once per matched location? Well, sure, but that might throw // up a lot of notifications... // TODO: Do that! break; } } // Now, the Globalhash notification gets tossed up regardless of the // user's preferences (apart from "Never"). if(!matchedGlobal.isEmpty()) { launchNotification(matchedGlobal, START_INFO_GLOBAL, R.id.alarm_known_location_global, R.string.known_locations_alarm_title_global); } } private void launchNotification(@NonNull List<KnownLocationMatchData> matched, @NonNull String action, @IdRes int notificationId, @StringRes int titleId) { // So here's what we do: Note the BEST match in a notification, but also // mention the others. Collections.sort(matched); // First one's the winner! Notification.Builder builder = getFreshNotificationBuilder(matched, titleId); Bundle bun = new Bundle(); bun.putParcelable(StockService.EXTRA_INFO, matched.get(0).bestInfo); Intent intent = new Intent(this, CentralMap.class) .setAction(action) .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) .putExtra(StockService.EXTRA_STUFF, bun); builder.setContentIntent(PendingIntent.getActivity(this, 0, intent, 0)); mNotificationManager.notify(notificationId, builder.build()); } private Notification.Builder getFreshNotificationBuilder(@NonNull List<KnownLocationMatchData> data, @StringRes int titleId) { KnownLocationMatchData match = data.get(0); String contentText = getString(R.string.known_locations_alarm_distance, UnitConverter.makeDistanceString(this, UnitConverter.DISTANCE_FORMAT_SHORT, (float)match.distance), match.knownLocation.getName()); String summaryText = getResources().getQuantityString(R.plurals.known_locations_alarm_more, data.size() - 1, data.size() - 1); Notification.Builder builder = new Notification.Builder(this) .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher)) .setSmallIcon(R.drawable.ic_stat_av_new_releases) .setAutoCancel(true) .setOngoing(false) .setLights(Color.WHITE, 500, 2000) .setContentText(contentText) .setContentTitle(getString(titleId)); // If there's more than one known location nearby, make the notification // expandable with a bit of extra text mentioning just how many more. if(data.size() > 1) { builder.setStyle(new Notification.BigTextStyle() .bigText(contentText) .setSummaryText(summaryText)); } // Since these notifications will be displaying location names, they // may as well be private. if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) builder.setVisibility(Notification.VISIBILITY_PRIVATE); return builder; } }