/** * WikiService.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.annotation.SuppressLint; 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.location.Location; import android.net.ConnectivityManager; import android.net.Uri; import android.os.Build; import android.os.PowerManager; import android.os.PowerManager.WakeLock; import android.preference.PreferenceManager; import android.util.Log; import net.exclaimindustries.geohashdroid.R; import net.exclaimindustries.geohashdroid.activities.LoginPromptDialog; import net.exclaimindustries.geohashdroid.util.GHDConstants; import net.exclaimindustries.geohashdroid.util.Graticule; import net.exclaimindustries.geohashdroid.util.Info; import net.exclaimindustries.geohashdroid.wiki.WikiException; import net.exclaimindustries.geohashdroid.wiki.WikiImageUtils; import net.exclaimindustries.geohashdroid.wiki.WikiUtils; import net.exclaimindustries.tools.AndroidUtil; import net.exclaimindustries.tools.DateTools; import net.exclaimindustries.tools.QueueService; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.util.Calendar; import java.util.HashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import cz.msebera.android.httpclient.impl.client.CloseableHttpClient; import cz.msebera.android.httpclient.impl.client.HttpClients; /** * <code>WikiService</code> is a background service that handles all wiki * communication. Note that you still need to come up with the actual DATA * yourself. This just does the talking to the server and queueing things up * for later if need be. * * @author Nicholas Killewald */ public class WikiService extends QueueService { /** * This is only here because {@link Notification.Action} doesn't exist in * API 16, which is what I'm targeting. Darn! It works astonishingly * similar to it, if by that you accept simply calling the API 16 version of * {@link Notification.Builder#addAction(int, CharSequence, PendingIntent)} * with the appropriate data to be "astonishingly similar", which I do. */ private class NotificationAction { public int icon; public PendingIntent actionIntent; public CharSequence title; public NotificationAction(int icon, PendingIntent actionIntent, CharSequence title) { this.icon = icon; this.actionIntent = actionIntent; this.title = title; } } /** * This listens for the connectivity broadcasts so we know if it's safe to * kick the queue back in action after a disconnect. Well... I guess not so * much "safe" as "possible". */ public static class WikiServiceConnectivityListener extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { if(intent.getAction().equals(ConnectivityManager.CONNECTIVITY_ACTION)) { // Ding! Are we back yet? if(AndroidUtil.isConnected(context)) { // Aha! We're up! Send off a command to resume the queue! Intent i = new Intent(context, WikiService.class); i.putExtra(QueueService.COMMAND_EXTRA, QueueService.COMMAND_RESUME); context.startService(i); } } } } private static final String DEBUG_TAG = "WikiService"; private NotificationManager mNotificationManager; private AlarmManager mAlarmManager; private WakeLock mWakeLock; /** Matches the gallery section. */ private static final Pattern RE_GALLERY = Pattern.compile("^(.*<gallery[^>]*>)(.*?)(</gallery>.*)$",Pattern.DOTALL); /** Matches the gallery section header. */ private static final Pattern RE_GALLERY_SECTION = Pattern.compile("^(.*== Photos ==)(.*)$",Pattern.DOTALL); /** Matches the expedition section. */ private static final Pattern RE_EXPEDITION = Pattern.compile("^(.*)(==+ ?Expedition ?==+.*?)(==+ ?.*? ?==+.*?)$",Pattern.DOTALL); /** How long we wait (in millis) before retrying a throttled edit. */ private static final long THROTTLE_DELAY = 60000; /** * The {@link Info} object for the current expedition. */ public static final String EXTRA_INFO = "net.exclaimindustries.geohashdroid.EXTRA_INFO"; /** * The timestamp when the original message was made (NOT when the message * ultimately gets posted). Should be a {@link Calendar}. */ public static final String EXTRA_TIMESTAMP = "net.exclaimindustries.geohashdroid.EXTRA_TIMESTAMP"; /** * The message to add to the expedition page or image caption. Should be a * String. */ public static final String EXTRA_MESSAGE = "net.exclaimindustries.geohashdroid.EXTRA_MESSAGE"; /** * Location of an image on the filesystem. Should be a {@link Uri} to * something that Android can find with a ContentResolver, preferably the * MediaStore. It'll be looking for DATA, LATITUDE, LONGITUDE, and * DATE_TAKEN from MediaStore.Images.ImageColumns. Can be ignored if * there's no image to upload. */ public static final String EXTRA_IMAGE = "net.exclaimindustries.geohashdroid.EXTRA_IMAGE"; /** * The user's current geographic coordinates. Should be a {@link Location}. * If not given, will assume the user's location is/was unknown. If posting * an image, any location metadata stored in that image will override this, * but if no such data exists there, this will be used instead. */ public static final String EXTRA_LOCATION = "net.exclaimindustries.geohashdroid.EXTRA_LOCATION"; /** * Whether or not the current location should be included with any upload. * That is, if this is false, the location won't be appended to messages and * infoboxes on images will claim the location is unknown. Though the same * effect can be achieved in a message post by not passing in * {@link #EXTRA_LOCATION}, this also overrides any location metadata in * images. */ public static final String EXTRA_INCLUDE_LOCATION = "net.exclaimindustries.geohashdroid.EXTRA_INCLUDE_LOCATION"; @Override public void onCreate() { super.onCreate(); // WakeLock awaaaaaay! PowerManager pm = (PowerManager)getSystemService(POWER_SERVICE); mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WikiService"); // Also, get the NotificationManager on standby. mNotificationManager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE); // How alarming. We need the AlarmManager. mAlarmManager = (AlarmManager)getSystemService(Context.ALARM_SERVICE); } @Override protected ReturnCode handleIntent(Intent i) { // First and foremost, if there's no network connection, just give up // now. if(!AndroidUtil.isConnected(this)) { showWaitingForConnectionNotification(); return ReturnCode.PAUSE; } // Hey, there, Intent. Got some extras for me? Info info; Location loc; String message; Calendar timestamp; Uri imageLocation; boolean includeLocation; try { info = i.getParcelableExtra(EXTRA_INFO); loc = i.getParcelableExtra(EXTRA_LOCATION); message = i.getStringExtra(EXTRA_MESSAGE); timestamp = (Calendar) i.getSerializableExtra(EXTRA_TIMESTAMP); imageLocation = i.getParcelableExtra(EXTRA_IMAGE); includeLocation = i.getBooleanExtra(EXTRA_INCLUDE_LOCATION, true); } catch(ClassCastException cce) { // If any of those threw a CCE, bail out. Log.e(DEBUG_TAG, "ClassCastException! Check your casts!", cce); return ReturnCode.CONTINUE; } // Prep an HttpClient for later... CloseableHttpClient client = HttpClients.createDefault(); // To Preferences! SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); String username = prefs.getString(GHDConstants.PREF_WIKI_USER, ""); String password = prefs.getString(GHDConstants.PREF_WIKI_PASS, ""); // If you're missing something vital, bail out. if(info == null || message == null || timestamp == null) { Log.e(DEBUG_TAG, "Intent was missing some vital data (either Info, message, or timestamp), giving up..."); return ReturnCode.CONTINUE; } // Also, if there's an image specified, make sure there's also a // username. The wiki does not allow anonymous image uploads. This // one, unlike the previous one, produces an interruption so the user // can enter in a username and password. if(imageLocation != null && username.isEmpty()) { showPausingErrorNotification(getString(R.string.wiki_conn_anon_pic_error), resolveWikiExceptionActions(new WikiException(R.string.wiki_conn_anon_pic_error))); return ReturnCode.PAUSE; } // Location becomes null if we're not including it. Nothing should need // to care. if(!includeLocation) loc = null; try { // If we got a username/password combo, try to log in. This throws // a WikiException if the login fails. if(!username.isEmpty() && !password.isEmpty()) { WikiUtils.login(client, username, password); } // Prep a page. We want a populated formfields for later. HashMap<String, String> formfields = new HashMap<>(); String expedition = WikiUtils.getWikiPageName(info); // This will be null if the page didn't exist to begin with. String page = WikiUtils.getWikiPage(client, expedition, formfields); // And if it IS null (or empty), then we ought to make said page. if(page == null || page.trim().isEmpty()) { // Aha! WikiUtils.putWikiPage(client, expedition, WikiUtils.getWikiExpeditionTemplate(info, this), formfields); // And once it's there, we pull it back, as we'll be futzing // about with it some more. page = WikiUtils.getWikiPage(client, expedition, formfields); } // I know this is making a monstrous, ugly method that's just a big // if statement, but I tried breaking this down into more specific // methods for image and not-image uploads, found there wasn't // enough in common between them, and wound up with methods with // ten or so arguments. If anyone else has a better idea, feel free // to suggest. if (imageLocation != null) { // Let's say there's an image specified. So, we try to look it // up via readImageInfo. WikiImageUtils.ImageInfo imageInfo; imageInfo = WikiImageUtils.readImageInfo(imageLocation, loc, timestamp); // Get the image's filename, too. Well, that is, the name it'll // have on the wiki. String wikiName = WikiImageUtils.getImageWikiName(info, imageInfo, username); // Make sure the image doesn't already exist. If it does, we // can skip the entire "shrink image, annotate it, and upload // it" steps. if(!WikiUtils.doesWikiPageExist(client, wikiName)) { // Get us a byte array! We'll be uploading this soon. byte[] image = WikiImageUtils.createWikiImage(this, info, imageInfo, includeLocation); if(image == null) { // No image is a problem at this point... showImageErrorNotification(); return ReturnCode.CONTINUE; } // And by "soon", I mean "right now", because that byte // array takes up a decent amount of memory. String description = message + "\n\n" + WikiUtils.getWikiCategories(info); WikiUtils.putWikiImage(client, wikiName, description, formfields, image); } // Good, good. Now, let's get some tags for posting. String locationTag = WikiUtils.makeLocationTag(loc); String prefixTag = WikiImageUtils.getImagePrefixTag(this, imageInfo, info); // The message is now going to be surrounded by tags. message = message.trim() + locationTag; // And the gallery entry is the name of the file plus that // message. String galleryEntry = "\nImage:" + wikiName + "|" + message + "\n"; // Then, add the gallery entry into the page... page = addGalleryEntryToPage(page, galleryEntry); // ...make a summary... formfields.put("summary", prefixTag + message); // ...and out it goes! WikiUtils.putWikiPage(client, expedition, page, formfields); } else { // If we DON'T have an image, it's just a plain message. That's // a lot easier than an image, but the posting's different, // slightly. String locationTag = WikiUtils.makeLocationTag(loc); // The summary gets a prefix depending on if it's a retro or // live post. Unlike images, "live" always applies if it's not // a retrohash. String summaryPrefix; if(info.isRetroHash()) summaryPrefix = getString(R.string.wiki_post_message_summary_retro); else summaryPrefix = getString(R.string.wiki_post_message_summary); formfields.put("summary", summaryPrefix + " " + message); // And now, insert text where need be on the page. String before; String after; if(page == null) { // This shouldn't happen. If it did, there's something very // wrong with the wiki. throw new WikiException(R.string.wiki_error_unknown); } Matcher expeditionq = RE_EXPEDITION.matcher(page); if (expeditionq.matches()) { before = expeditionq.group(1) + expeditionq.group(2); after = expeditionq.group(3); } else { // If the expedition section doesn't exist, well, just slap // it onto the end of the page. This shouldn't happen // unless someone's mucking about with the page on the web. before = page; after = ""; } String localtime = DateTools.getWikiDateString(timestamp); // Attach requisite tags to the message... message = "\n*" + message + " -- ~~~" + locationTag + " " + localtime + "\n"; // And go! WikiUtils.putWikiPage(client, expedition, before + message + after, formfields); } return ReturnCode.CONTINUE; } catch (WikiException we) { // There's two possible exceptions we want to keep an eye on, both // of them related to throttling. Since we're potentially posting // numerous edits one right after another (i.e. if the user's been // away from a network connection and has ten or so live updates // queued up), throttling IS possible, and that can be handled by // waiting it out for a minute or so. if(we.getErrorTextId() == R.string.wiki_error_throttled || we.getErrorTextId() == R.string.wiki_error_rate_limit) { showThrottleNotification(); } else { // Otherwise, throw a normal notification. showPausingErrorNotification(getString(we.getErrorTextId()), resolveWikiExceptionActions(we)); } return ReturnCode.PAUSE; } catch (Exception e) { // Okay, first off, are we still connected? An Exception will get // thrown if the connection just goes poof while we're trying to do // something. if(!AndroidUtil.isConnected(this)) { // We're not! Go to disconnected mode and wait. showWaitingForConnectionNotification(); } else { // Otherwise, we're kinda stumped. Maybe the user will know // what to do? Log.e(DEBUG_TAG, "Unknown wiki problem", e); showPausingErrorNotification(getString(R.string.wiki_notification_general_error), resolveWikiExceptionActions(null)); } return ReturnCode.PAUSE; } finally { try { client.close(); } catch(Exception ex) { // Eh, forget it. } } } @Override protected void onQueueStart() { // WAKELOCK! Front and center! mWakeLock.acquire(); // If we're starting, that means we're not waiting anymore. Makes // sense. hideWaitingForConnectionNotification(); hideThrottleNotification(); hidePausingErrorNotification(); // Plus, throw up a NEW Notification. This one should stick around // until we're done, one way or another. showActiveNotification(); } @Override protected void onQueuePause(Intent i) { // Aaaaand wakelock stop. if(mWakeLock.isHeld()) mWakeLock.release(); // Notification goes away, too. removeActiveNotification(); } @Override protected void onQueueEmpty(boolean allProcessed) { // Done! Wakelock go away now. if(mWakeLock.isHeld()) mWakeLock.release(); // Notifications go boom, too. removeActiveNotification(); // We might get an abort during pause, so... hidePausingErrorNotification(); } @Override protected void serializeToDisk(Intent i, OutputStream os) { try { // We'll encode one line per object, mashing the message into one // URI-encoded line. OutputStreamWriter osw = new OutputStreamWriter(os); StringBuilder builder = new StringBuilder(); // Always write out the \n, even if it's null. An empty line will // be deserialized as a null. Yes, even if that'll cause an error // later. // The date can come in as a long. Calendar c = (Calendar)i.getSerializableExtra(EXTRA_TIMESTAMP); if(c != null) builder.append(c.getTimeInMillis()); builder.append('\n'); // The location is just two doubles. Split 'em with a colon. Location loc = i.getParcelableExtra(EXTRA_LOCATION); if(loc != null) builder.append(Double.toString(loc.getLatitude())) .append(':') .append(Double.toString(loc.getLongitude())); builder.append('\n'); // The image is just a URI. Easy so far. Uri uri = i.getParcelableExtra(EXTRA_IMAGE); if(uri != null) builder.append(uri.toString()); builder.append('\n'); // And now comes Info. It encompasses two doubles (the // destination), a Date (the date of the expedition), and a // Graticule (two ints and two booleans). The Graticule part can be // null if this is a globalhash. Info info = i.getParcelableExtra(EXTRA_INFO); if(info != null) { builder.append(Double.toString(info.getLatitude())) .append(':') .append(Double.toString(info.getLongitude())) .append(':') .append(Long.toString(info.getDate().getTime())) .append(':'); Graticule g = info.getGraticule(); if(g != null) { builder.append(Integer.toString(g.getLatitude())) .append(':') .append(g.isSouth() ? '1' : '0') .append(':') .append(Integer.toString(g.getLongitude())) .append(':') .append((g.isWest() ? '1' : '0')); } } builder.append('\n'); // The rest of it is the message. We'll URI-encode it so it comes // out as a single string without line breaks. String message = i.getStringExtra(EXTRA_MESSAGE); if(message != null) builder.append(Uri.encode(message)); // Right... let's write it out. osw.write(builder.toString()); } catch (Exception e) { // If we got an exception, we're in deep trouble. Log.e(DEBUG_TAG, "Exception when serializing an Intent!", e); } } @Override protected Intent deserializeFromDisk(InputStream is) { // Now we go the other way around. InputStreamReader isr = new InputStreamReader(is); BufferedReader br = new BufferedReader(isr); Intent toReturn = new Intent(); try { // Date, as a long. String read = br.readLine(); if(read != null && !read.isEmpty()) { Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(Long.parseLong(read)); toReturn.putExtra(EXTRA_TIMESTAMP, cal); } // Location, as two doubles. read = br.readLine(); if(read != null && !read.isEmpty()) { String parts[] = read.split(":"); Location loc = new Location(""); loc.setLatitude(Double.parseDouble(parts[0])); loc.setLongitude(Double.parseDouble(parts[1])); toReturn.putExtra(EXTRA_LOCATION, loc); } // Image URI, as a string. read = br.readLine(); if(read != null && !read.isEmpty()) { Uri file = Uri.parse(read); toReturn.putExtra(EXTRA_IMAGE, file); } // The Info object, as a mess of things. read = br.readLine(); if(read != null && !read.isEmpty()) { String parts[] = read.split(":"); double lat = Double.parseDouble(parts[0]); double lon = Double.parseDouble(parts[1]); Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(Long.parseLong(parts[2])); Graticule grat = null; // If there's less than seven elements, this is a null Graticule // and thus a globalhash. Otherwise... if(parts.length >= 7) { int glat = Integer.parseInt(parts[3]); boolean gsouth = parts[4].equals("1"); int glon = Integer.parseInt(parts[5]); boolean gwest = parts[6].equals("1"); grat = new Graticule(glat, gsouth, glon, gwest); } // And now we can form an Info. toReturn.putExtra(EXTRA_INFO, new Info(lat, lon, grat, cal)); } // Finally, the message. This is just one URI-encoded string. read = br.readLine(); if(read != null && !read.isEmpty()) toReturn.putExtra(EXTRA_MESSAGE, Uri.decode(read)); // There! Rebuilt! return toReturn; } catch (IOException e) { Log.e(DEBUG_TAG, "Exception when deserializing an Intent!" , e); return null; } } @Override protected boolean resumeOnNewIntent() { return false; } private void showActiveNotification() { Notification.Builder builder = getFreshNotificationBuilder() .setOngoing(true) .setContentTitle(getString(R.string.wiki_notification_title)) .setContentText("") .setSmallIcon(R.drawable.ic_stat_file_file_upload); mNotificationManager.notify(R.id.wiki_working_notification, builder.build()); } private void removeActiveNotification() { mNotificationManager.cancel(R.id.wiki_working_notification); } private void showImageErrorNotification() { // This shouldn't happen, but a spare notification to explain that an // image was canceled would be nice just in case it does. It'll be an // auto-cancel, too, so the user can just remove it as need be, as we're // not going to touch it past this. Also, the string says "one or more // images", so that'll cover it if we somehow get LOTS of broken image // URIs. Notification.Builder builder = getFreshNotificationBuilder() .setAutoCancel(true) .setOngoing(false) .setContentTitle(getString(R.string.wiki_notification_image_error_title)) .setContentText(getString(R.string.wiki_notification_image_error_content)) .setSmallIcon(R.drawable.ic_stat_alert_warning); mNotificationManager.notify(R.id.wiki_image_error_notification, builder.build()); } private void showWaitingForConnectionNotification() { Notification.Builder builder = getFreshNotificationBuilder() .setOngoing(true) .setContentTitle(getString(R.string.wiki_notification_waiting_for_connection_title)) .setContentText(getString(R.string.wiki_notification_waiting_for_connection_content)) .setSmallIcon(R.drawable.ic_stat_navigation_more_horiz); mNotificationManager.notify(R.id.wiki_waiting_notification, builder.build()); // Make sure the connectivity listener's waiting for a connection. AndroidUtil.setPackageComponentEnabled(this, WikiServiceConnectivityListener.class, true); } private void hideWaitingForConnectionNotification() { mNotificationManager.cancel(R.id.wiki_waiting_notification); AndroidUtil.setPackageComponentEnabled(this, WikiServiceConnectivityListener.class, false); } private void showPausingErrorNotification(String reason, NotificationAction[] actions) { // This one (hopefully) gets its own PendingIntent (preferably something // that'll help solve the problem, like a username prompt). Notification.Builder builder = getFreshNotificationBuilder() .setContentTitle(getString(R.string.wiki_notification_error_title)) .setContentText(reason) .setSmallIcon(R.drawable.ic_stat_alert_error); if (actions.length >= 1 && actions[0] != null) { builder.setContentIntent(actions[0].actionIntent); builder.addAction(actions[0].icon, actions[0].title, actions[0].actionIntent); } if (actions.length >= 2 && actions[1] != null) builder.addAction(actions[1].icon, actions[1].title, actions[1].actionIntent); if (actions.length >= 3 && actions[2] != null) builder.addAction(actions[2].icon, actions[2].title, actions[2].actionIntent); mNotificationManager.notify(R.id.wiki_error_notification, builder.build()); } private void hidePausingErrorNotification() { mNotificationManager.cancel(R.id.wiki_error_notification); } private void showThrottleNotification() { // Throttling just means we wait a minute before we try again. The user // is free to force the issue, however. Notification.Builder builder = getFreshNotificationBuilder() .setAutoCancel(true) .setOngoing(true) .setContentTitle(getString(R.string.wiki_notification_throttle_title)) .setContentText(getString(R.string.wiki_notification_throttle_content)) .setContentIntent(getBasicCommandIntent(QueueService.COMMAND_RESUME)) .setSmallIcon(R.drawable.ic_stat_av_av_timer); mNotificationManager.notify(R.id.wiki_throttle_notification, builder.build()); // Also, get the alarm ready. mAlarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + THROTTLE_DELAY, getBasicCommandIntent(QueueService.COMMAND_RESUME)); } private void hideThrottleNotification() { mNotificationManager.cancel(R.id.wiki_throttle_notification); mAlarmManager.cancel(getBasicCommandIntent(QueueService.COMMAND_RESUME)); } @SuppressLint("NewApi") private Notification.Builder getFreshNotificationBuilder() { // This just returns a fresh new Notification.Builder with the default // images. We're resetting everything on each notification anyway, so // sharing the object is sort of a waste. Notification.Builder builder = new Notification.Builder(this) .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher)); if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) builder.setVisibility(Notification.VISIBILITY_PUBLIC); return builder; } private String addGalleryEntryToPage(String page, String galleryEntry) { String before; String after; Matcher galleryq = RE_GALLERY.matcher(page); if (galleryq.matches()) { before = galleryq.group(1) + galleryq.group(2); after = galleryq.group(3); } else { // If we didn't match the gallery, find the Photos section // and create a new gallery in it. Matcher photosq = RE_GALLERY_SECTION.matcher(page); if(photosq.matches()) { before = photosq.group(1) + "\n<gallery>"; after = "</gallery>\n" + photosq.group(2); } else { // If we STILL can't find it, just tack it on to the end // of the page. before = page + "\n<gallery>"; after = "</gallery>\n"; } } // Mash it all together. return before + galleryEntry + after; } private NotificationAction[] resolveWikiExceptionActions(WikiException we) { // This'll get the (up to) three NotificationActions associated with a // given WikiException (identified by string ID). int id = -1; if(we != null) id = we.getErrorTextId(); NotificationAction[] toReturn = new NotificationAction[]{null,null,null}; switch(id) { case R.string.wiki_conn_anon_pic_error: case R.string.wiki_error_bad_password: case R.string.wiki_error_bad_username: case R.string.wiki_error_username_nonexistant: toReturn[0] = new NotificationAction( 0, PendingIntent.getActivity(this, 0, new Intent(this, LoginPromptDialog.class), PendingIntent.FLAG_UPDATE_CURRENT), getString(R.string.wiki_notification_action_update_login) ); toReturn[1] = getBasicNotificationAction(COMMAND_ABORT); break; default: // As a general case (or if a null was passed in), we just use // the standard retry, skip, or abort choices. This works for a // surprising amount of cases, it turns out. Simplicity wins! toReturn[0] = getBasicNotificationAction(COMMAND_RESUME); toReturn[1] = getBasicNotificationAction(COMMAND_RESUME_SKIP_FIRST); toReturn[2] = getBasicNotificationAction(COMMAND_ABORT); } return toReturn; } private PendingIntent getBasicCommandIntent(int command) { // This will just call back to the service with the given command. return PendingIntent.getService(this, 0, new Intent(this, WikiService.class).putExtra(QueueService.COMMAND_EXTRA, command), 0); } private NotificationAction getBasicNotificationAction(int command) { switch(command) { case COMMAND_RESUME: return new NotificationAction( 0, getBasicCommandIntent(QueueService.COMMAND_RESUME), getString(R.string.wiki_notification_action_retry) ); case COMMAND_RESUME_SKIP_FIRST: return new NotificationAction( 0, getBasicCommandIntent(QueueService.COMMAND_RESUME_SKIP_FIRST), getString(R.string.wiki_notification_action_skip) ); case COMMAND_ABORT: return new NotificationAction( 0, getBasicCommandIntent(QueueService.COMMAND_ABORT), getString(R.string.wiki_notification_action_abort) ); default: return null; } } }