net.exclaimindustries.geohashdroid.services.WikiService.java Source code

Java tutorial

Introduction

Here is the source code for net.exclaimindustries.geohashdroid.services.WikiService.java

Source

/**
 * 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.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.database.Cursor;
import android.location.Location;
import android.net.Uri;
import android.os.Build;
import android.os.PowerManager;
import android.os.PowerManager.WakeLock;
import android.provider.MediaStore;
import android.util.Log;

import net.exclaimindustries.geohashdroid.R;
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.WikiUtils;
import net.exclaimindustries.tools.AndroidUtil;
import net.exclaimindustries.tools.QueueService;

import org.apache.http.client.HttpClient;
import org.apache.http.impl.client.DefaultHttpClient;

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;

/**
 * <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 just a convenient holder for the various info related to an
     * image.
     */
    private class ImageInfo {
        public Uri uri;
        public String filename;
        public Location location;
        public long timestamp;
    }

    /**
     * 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, android.app.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) {
            // 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 WakeLock mWakeLock;

    /**
     * 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.
     *
     * TODO: Maybe some more flexible way of fetching an image?  Dunno.
     */
    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";

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

    @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 = (Info) i.getSerializableExtra(EXTRA_INFO);
        Location loc = (Location) i.getSerializableExtra(EXTRA_LOCATION);
        String message = i.getStringExtra(EXTRA_MESSAGE);
        Calendar timestamp = (Calendar) i.getSerializableExtra(EXTRA_TIMESTAMP);
        Uri imageLocation = (Uri) i.getParcelableExtra(EXTRA_IMAGE);

        // Prep an HttpClient for later...
        HttpClient client = new DefaultHttpClient();

        // To Preferences!
        SharedPreferences prefs = getSharedPreferences(GHDConstants.PREFS_BASE, 0);
        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;
        }

        try {
            // If we got a username/password combo, try to log in.
            if (!username.isEmpty() && !password.isEmpty()) {
                WikiUtils.login(client, username, password);
            }

            // Let's say there's an image specified.
            ImageInfo imageInfo;
            if (imageLocation != null) {
                // If so, see if the user's even specified a login.  The wiki does
                // not allow anonymous uploads.

                if (username.equals("")) {
                    // Aww.  Failure.
                    // TODO: Need a real PendingIntent here!
                    showPausingErrorNotification(getText(R.string.wiki_conn_anon_pic_error).toString(), null, null,
                            null);
                    return ReturnCode.PAUSE;
                }

                // If that's all set, we can try to look it up on the system.
                imageInfo = readImageInfo(imageLocation, loc);

                // But, if said info remains null, we've got a problem.  The user
                // wanted an image uploaded, but we can't do that, so we have to
                // abandon this intent.  However, I don't think that's a showstopper
                // in terms of continuing the queue.
                if (imageInfo == null) {
                    Log.e(DEBUG_TAG,
                            "The user was somehow allowed to choose an image that can't be accessed via MediaStore!");
                    showImageErrorNotification();
                    return ReturnCode.CONTINUE;
                }

                // Now, the location that we're going to send for the image SHOULD
                // match up with where the user thinks they are, so we'll read what
                // got stuffed into the ImageInfo.  Note that we just gave it the
                // user's current location in the event that MediaStore doesn't have
                // any idea, either, so we're not going to replace good data with a
                // null, if said good data exists.
                loc = imageInfo.location;

                // 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, getImageWikiName(info, imageInfo, username))) {
                    // TODO: Create bitmap and upload it.
                }
            }

            return ReturnCode.CONTINUE;
        } catch (WikiException we) {
            // TODO: Handle wiki exceptions.
        } 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();
                return ReturnCode.PAUSE;
            } else {
                // Otherwise, we're kinda stumped.  Maybe the user will know
                // what to do?
                // TODO: Handle other exceptions.
            }
        }

        // We shouldn't be here.
        return ReturnCode.PAUSE;
    }

    @Override
    protected void onQueueStart() {
        // WAKELOCK!  Front and center!
        mWakeLock.acquire();

        // If we're starting, that means we're not waiting anymore.  Makes
        // sense.
        hideWaitingForConnectionNotification();

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

        // Notification go boom, too.
        removeActiveNotification();
    }

    @Override
    protected void serializeToDisk(Intent i, OutputStream os) {
        // We'll encode one line per object, with the last lines reserved for
        // the entire message (the only thing of these that can have multiple
        // lines).
        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.getParcelableExtra(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 = (Location) 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 = (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 = (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(':');

            if (!info.isGlobalHash()) {
                Graticule g = info.getGraticule();
                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.
        try {
            osw.write(builder.toString());
        } catch (IOException 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("");

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

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

        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 action1, NotificationAction action2,
            NotificationAction action3) {
        // This one (hopefully) gets its own PendingIntent (preferably something
        // that'll help solve the problem, like a username prompt).
        Notification.Builder builder = getFreshNotificationBuilder().setAutoCancel(true)
                .setContentTitle(getString(R.string.wiki_notification_error_title)).setContentText(reason);

        if (action1 != null) {
            builder.setContentIntent(action1.actionIntent);
            builder.addAction(action1.icon, action1.title, action1.actionIntent);
        }

        if (action2 != null)
            builder.addAction(action2.icon, action2.title, action2.actionIntent);
        if (action3 != null)
            builder.addAction(action3.icon, action3.title, action3.actionIntent);

        mNotificationManager.notify(R.id.wiki_error_notification, builder.build());
    }

    @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)
                .setSmallIcon(R.drawable.geohashing_logo_notification);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
            builder.setVisibility(Notification.VISIBILITY_PUBLIC);

        return builder;
    }

    private ImageInfo readImageInfo(Uri uri, Location locationIfNoneSet) {
        // We're hoping this is something that MediaStore understands.  If not,
        // or if the image doesn't exist anyway, we're returning null, which is
        // interpreted by the intent handler to mean there's no image here, so
        // an error should be thrown.
        ImageInfo toReturn = null;

        if (uri != null) {
            Cursor cursor;
            cursor = getContentResolver().query(uri,
                    new String[] { MediaStore.Images.ImageColumns.DATA, MediaStore.Images.ImageColumns.LATITUDE,
                            MediaStore.Images.ImageColumns.LONGITUDE, MediaStore.Images.ImageColumns.DATE_TAKEN },
                    null, null, null);

            if (cursor == null || cursor.getCount() < 1) {
                if (cursor != null)
                    cursor.close();
                return null;
            }

            cursor.moveToFirst();

            toReturn = new ImageInfo();
            toReturn.uri = uri;
            toReturn.filename = cursor.getString(0);
            toReturn.timestamp = cursor.getLong(3);

            // These two could very well be null or empty.  Nothing wrong with
            // that.  But if they're good, make a Location out of them.
            String lat = cursor.getString(1);
            String lon = cursor.getString(2);

            Location toSet;
            try {
                double llat = Double.parseDouble(lat);
                double llon = Double.parseDouble(lon);
                toSet = new Location("");
                toSet.setLatitude(llat);
                toSet.setLongitude(llon);
            } catch (Exception ex) {
                // If we get an exception, we got it because of the number
                // parser.  Assume it's invalid and we're using the user's
                // current location, if that's even known (that might ALSO be
                // null, in which case we just don't have any clue where the
                // user is, which seems a bit counterintuitive to how
                // Geohashing is supposed to work).
                toSet = locationIfNoneSet;
            }

            // Now toss the location into the info.
            toReturn.location = toSet;

            cursor.close();
        }

        return toReturn;
    }

    private String getImageWikiName(Info info, ImageInfo imageInfo, String username) {
        // Just to be clear, this is the wiki page name (expedition and all),
        // the username, and the image's timestamp (as millis past the epoch).
        return WikiUtils.getWikiPageName(info) + "_" + username + "_" + imageInfo.timestamp + ".jpg";
    }
}