/** * HashBuilder.java * Copyright (C)2009 Nicholas Killewald * * This file is distributed under the terms of the BSD license. * The source package should have a LICENSE file at the toplevel. */ package net.exclaimindustries.geohashdroid.util; import android.content.Context; import android.support.annotation.Nullable; import android.util.Log; import net.exclaimindustries.tools.DateTools; import net.exclaimindustries.tools.HexFraction; import net.exclaimindustries.tools.MD5Tools; import java.io.BufferedReader; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.security.InvalidParameterException; import java.util.Calendar; import java.util.Timer; import java.util.TimerTask; import cz.msebera.android.httpclient.HttpResponse; import cz.msebera.android.httpclient.client.methods.HttpGet; import cz.msebera.android.httpclient.impl.client.CloseableHttpClient; import cz.msebera.android.httpclient.impl.client.HttpClients; /** * <p> * The <code>HashBuilder</code> class encompasses a whole bunch of static * methods to grab and store the day's DJIA and calculate the hash, given a * <code>Graticule</code> object. * </p> * * <p> * This also encompasses <code>StockRunner</code>, which goes out to the web * to get the current stock data. <code>HashBuilder</code> itself, though, * does the hash calculations. * </p> * * <p> * This implementation uses the Crox site to get the DJIA, falling back to * the peeron.com site if Crox can't figure it out (upstream faults, server * failure, etc). * </p> * * @author Nicholas Killewald */ public class HashBuilder { // This is used as the lock to prevent multiple requests from happening at // once. This really shouldn't ever happen, but just in case. private static final Object locker = new Object(); private static final String DEBUG_TAG = "HashBuilder"; private static StockStoreDatabase mStore; // This set allows for quick reloading of the most recent stock and hash in // a given instance of the program, bypassing the SQLite database, as well // as allow for a small cache even if the SQLite database is turned off by // preferences. private static Info mLastInfo; private static Info mTwoInfosAgo; /** * <code>StockRunner</code> is what fetches the stocks. It spawns off * threads to fetch data, and once {@link #runStock()} returns, you'll be * able to pull the data and act on it. Once it has the data, it'll go back * to the static methods of HashBuilder to make the Info bundle and put it * in the cache. */ public static class StockRunner { private static final String DEBUG_TAG = "StockRunner"; // In milliseconds, remember. private static final int CONNECTION_TIMEOUT_SEC = 10; private static final int CONNECTION_TIMEOUT_MS = CONNECTION_TIMEOUT_SEC * 1000; /** * This is busy, either with getting the stock price or working out * the hash. */ public static final int BUSY = 0; /** * This hasn't been started yet and has no Info object handy. */ public static final int IDLE = 1; /** * This is done, and its last action was successful, in that it got * stock data and calculated a new hash. If this is returned from * getStatus, you can get a fresh Info object. */ public static final int ALL_OKAY = 2; /** * The last request couldn't be met because the stock value wasn't * posted for the given day yet. */ public static final int ERROR_NOT_POSTED = 3; /** * The last request couldn't be met because of some server error. */ public static final int ERROR_SERVER = 4; // /** // * The user aborted the request. // */ // public static final int ABORTED = 5; private Context mContext; private Calendar mCal; private Graticule mGrat; private HttpGet mRequest; private int mStatus; private Info mLastObject; // This may be expanded later to allow a user-definable list, hence why // it doesn't follow the usual naming conventions I use. Of course, in // THAT case, we'd need to make it not be a raw array. The general form // is that %Y is the four-digit year, %m is the zero-padded month, and // %d is the zero-padded date. private final static String[] mServers = { "http://irc.peeron.com/xkcd/map/data/%Y/%m/%d", "http://geo.crox.net/djia/%Y/%m/%d" }; private StockRunner(Context con, Calendar c, Graticule g) { mContext = con; mCal = c; mGrat = g; mStatus = IDLE; } /** <p> * Runs the stock fetch in the current thread. And by "current thread", * I mean don't use this if you're in the main thread. It's stupid and * wrong to put network I/O on the main thread. * </p> * * <p> * When this method returns, the cache will have been updated, if * appropriate. You can retrieve the status and data from * {@link #getStatus()} and {@link #getLastResultObject()}. * </p> */ public void runStock() { Info toReturn; String stock; mStatus = BUSY; // First, we need to adjust the calendar in the event we're in the // range of the 30W rule. To that end, sCal is for stock calendar. Calendar sCal = Info.makeAdjustedCalendar(mCal, mGrat); // Grab a lock on our lock object. synchronized(locker) { // First, if this exists in the cache, use it instead of going // off to the internet. This method uses the ACTUAL date, so // we can ignore sCal for now. toReturn = getStoredInfo(mContext, mCal, mGrat); if(toReturn != null) { // Hey, whadya know, we've got something! Send this data // back to the Handler and return! mStatus = ALL_OKAY; sendMessage(toReturn); return; } // If that failed, we need a stock price. First, check to see // if it's in the database. stock = getStoredStock(mContext, sCal); // If we found something, great! Let's move on! if(stock == null) { // Otherwise, we need to start heading off to the net. mStatus = BUSY; try { stock = fetchStock(sCal); // If this didn't throw an exception AND it's not blank, // stash it in the database. if(stock.trim().length() != 0) storeStock(mContext, sCal, stock); } catch (FileNotFoundException fnfe) { // If we got a 404, assume it's not posted yet. mStatus = ERROR_NOT_POSTED; sendMessage(createInvalidInfo(mCal, mGrat)); return; } catch (IOException ioe) { // If we got anything else, assume a problem. mStatus = ERROR_SERVER; sendMessage(createInvalidInfo(mCal, mGrat)); return; } } } // We assemble an Info object and get ready to return it. This uses // the REAL date so we display the right thing on the detail screen // (or anywhere else; the point is, we can report to the user if // they're in the influence of the 30W Rule). toReturn = createInfo(mCal, stock, mGrat); // Good! Now, we can stash this away in the database for later. storeInfo(mContext, toReturn); // And we're done! mStatus = ALL_OKAY; sendMessage(toReturn); } private void sendMessage(Info toReturn) { mLastObject = toReturn; } /** * Returns the last result Info created from this StockRunner. This is * used when the result comes when no handler is defined and it needs to * be pulled out. This may be null. Always remember to check the * status first and ONLY do this if an ALL_OKAY is returned. * * @return the last Info created from this StockRunner (may be null) */ public Info getLastResultObject() { return mLastObject; } private String fetchStock(Calendar sCal) throws IOException { // Now, generate a string for the URL. String sMonthStr; String sDayStr; if (sCal.get(Calendar.MONTH) + 1 < 10) sMonthStr = "0" + (sCal.get(Calendar.MONTH) + 1); else sMonthStr = Integer.valueOf(sCal.get(Calendar.MONTH) + 1).toString(); if (sCal.get(Calendar.DAY_OF_MONTH) < 10) sDayStr = "0" + sCal.get(Calendar.DAY_OF_MONTH); else sDayStr = Integer.valueOf(sCal.get(Calendar.DAY_OF_MONTH)).toString(); // Good, good! Now, to the web! Go through our list of sites in // order until we find an answer, we bottom out, or we abort. In // terms of what we report to the user, "Server error" is lowest- // priority, with "Stock not posted" rating above it. That is to // say, if one server reports and error but another one explicitly // tells us the stock wasn't found, the latter is what we use. Of // course, if we get an abort request, that takes absolute // precedence. int curStatus = ERROR_SERVER; String result = ""; for(String s : mServers) { // Do all our substitutions... String location = s.replaceAll("%Y", Integer.toString(sCal.get(Calendar.YEAR))); location = location.replaceAll("%m", sMonthStr); location = location.replaceAll("%d", sDayStr); Log.v(DEBUG_TAG, "Trying " + location + "..."); // And go fetch! CloseableHttpClient client = HttpClients.createDefault(); mRequest = new HttpGet(location); HttpResponse response; // Get ready to time out if need be. You never know. TimerTask task = new TimerTask() { @Override public void run() { Log.i(DEBUG_TAG, "Stock fetch connection timed out, aborting now."); try { mRequest.abort(); } catch (NullPointerException npe) { // It COULD be null at that point. If it is, we can // just safely ignore it. } } }; // Timer goes now! We'll start the client immediately in the // upcoming try block. new Timer(true).schedule(task, CONNECTION_TIMEOUT_MS); try { response = client.execute(mRequest); task.cancel(); // If that came out aborted, it was a timeout, so move on. if(mRequest.isAborted()) continue; } catch (IOException e) { // If there was an exception, there was some issue with the // server. It might've been aborted by timeout, but still, // move on to the next server. continue; } if (response.getStatusLine().getStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) { curStatus = ERROR_NOT_POSTED; } else if (response.getStatusLine().getStatusCode() != HttpURLConnection.HTTP_OK) { // A non-okay response that isn't a 404 is bad. Count this // one as ERROR_SERVER and just continue. continue; } // Well, we got this far! Let's read! result = getStringFromStream(response.getEntity().getContent()); // With that done, we try to convert the output to the float. // If this fails, we got bogus data and should roll on. try { //noinspection ResultOfMethodCallIgnored Float.parseFloat(result); } catch (NumberFormatException nfe) { result = ""; continue; } // We survived! Set the status flag and keep going! curStatus = ALL_OKAY; client.close(); break; } // If we got this far and we still had an ERROR_SERVER or // ERROR_NOT_POSTED, throw 'em. We failed. if(curStatus == ERROR_NOT_POSTED) throw new FileNotFoundException(); else if(curStatus == ERROR_SERVER) throw new IOException(); // If we finally, FINALLY got this far, we've got a successful stock! return result; } /** * Takes the given stream and makes a String out of whatever data it has. Be * really careful with this, as it will just attempt to read whatever's in * the stream until it stops, meaning it'll spin endlessly if this isn't the * sort of stream that ends. * * @param stream * InputStream to read from * @return a String consisting of the data from the stream */ protected static String getStringFromStream(InputStream stream) throws IOException { BufferedReader buff = new BufferedReader(new InputStreamReader(stream)); // Load it up... StringBuilder tempstring = new StringBuilder(); char bean[] = new char[1024]; int read; while ((read = buff.read(bean)) != -1) { tempstring.append(bean, 0, read); } return tempstring.toString(); } /** * Returns whatever the current status is. This is returned as a part * of the Handler callback, but if, for instance, the Activity was * destroyed between the call to get the stock value and the time it * actually got it, the new caller will need to come here for the status. * * @return the current status */ public int getStatus() { return mStatus; } } // You don't construct a HashBuilder! You gotta EARN it! private HashBuilder() { } /** * Initializes and returns a StockStoreDatabase object. This should be used * in ALL cases the mStore is needed to ensure it actually exists. It can, * for instance, stop existing if the app is destroyed to reclaim memory. * * @param c Context with which StockStoreDatabase will be initialized. * @return a new StockStoreDatabase object */ private static synchronized StockStoreDatabase getStore(Context c) { if(mStore == null) { mStore = new StockStoreDatabase().init(c); } return mStore; } /** * Requests a <code>StockRunner</code> object to perform a stock-fetching * operation. * * @param con Context for databasey stuff * @param c Calendar object with the adventure date requested (this will * account for the 30W Rule, so don't put it in) * @param g Graticule to use */ public static StockRunner requestStockRunner(Context con, Calendar c, Graticule g) { return new StockRunner(con, c, g); } /** * Attempt to construct an Info object from stored info and return it, * explicitly without going to the internet. If this can't be done, this * will return null. * * @param con Context used to retrieve the database, if needed * @param c Calendar object with the adventure date requested (this will * account for the 30W Rule, so don't put it in) * @param g Graticule to use * @return the Info object for the given data, or null if can't be built * without going to the internet. */ @Nullable public static Info getStoredInfo(Context con, Calendar c, Graticule g) { // First, check the quick cache. If it's in the quick cache, use it. Log.v(DEBUG_TAG, "Checking caches for " + DateTools.getDateString(c) + ((g == null || g.uses30WRule()) ? " with 30W rule" : " without 30W rule")); Info result = getQuickCache(c, g); if(result != null) { Log.v(DEBUG_TAG, "Data found in quickcache!"); if(result.isGlobalHash()) return result; else return cloneInfo(result, g); } // Otherwise, check the stock cache. Info i = getStore(con).getInfo(c, g); if(i == null) return null; Log.v(DEBUG_TAG, "Data found in database! Quickcaching..."); // If it was in the main cache but not the quick cache, quick cache it. quickCache(i); return i; } /** * Attempt to get the stock value stored in the database for the given * already-adjusted date. This won't go to the internet; that's the * responsibility of a StockRunner. * * @param con Context used to retrieve the database, if needed * @param c already-adjusted date to check * @return the String representation of the stock, or null if it's not there */ public static String getStoredStock(Context con, Calendar c) { // We don't quickcache the stock values. Log.v(DEBUG_TAG, "Going to the database for a stock for " + DateTools.getDateString(c)); return getStore(con).getStock(c); } /** * Puts the given data into the quick cache. Note that the Calendar object * is the date of the stock, not the date of the expedition. * * @param i Info to store */ private static void quickCache(Info i) { // Slide over! mTwoInfosAgo = mLastInfo; mLastInfo = i; } /** * Stores Info data away in the database. This won't do anything if the * day's Info already exists therein. * * @param con Context used to retrieve the database, if needed * @param i an Info bundle with everything we need */ private synchronized static void storeInfo(Context con, Info i) { // First, replace the last-known results. quickCache(i); StockStoreDatabase store = getStore(con); // Then, write it to the database. store.storeInfo(i); store.cleanup(con); } private synchronized static void storeStock(Context con, Calendar cal, String stock) { StockStoreDatabase store = getStore(con); store.storeStock(cal, stock); store.cleanup(con); } /** * Wipes out the entire stock cache. No, seriously. * * @param con Context used to retrieve the database * @return true on success, false on failure */ public synchronized static boolean deleteCache(Context con) { return getStore(con).deleteCache(); } /** * Build an Info object. Since this assumes we already have a stock price * AND the Graticule can tell us if we need to use the 30W rule, use the * REAL date on the Calendar object. * * @param c date from which this hash comes * @param stockPrice effective stock price (already adjusted for the 30W Rule) * @param g the graticule in question * @return a new Info object */ protected static Info createInfo(Calendar c, String stockPrice, Graticule g) { // This creates the Info object that'll go right back to whatever was // calling it. In general, this is the Handler in StockRunner. // So to that end, we first build up the hash. String hash = makeHash(c, stockPrice); // Then, get the latitude and longitude from that. double lat = getLatitude(g, hash); double lon = getLongitude(g, hash); // And finally... return new Info(lat, lon, g, c); } /** * Build an Info object marked as invalid. This is for error-reporting. * * @param c date from which this hash should've come * @param g the graticule in question * @return an Info object marked invalid */ protected static Info createInvalidInfo(Calendar c, Graticule g) { return new Info(g, c); } /** * Builds a new Info object by applying a new Graticule to an existing Info * object. That is to say, change the destination of an Info object to * somewhere else, as if it were the same day and same stock value (and * thus the same hash). Note that this will throw an exception if the * existing Info's 30W-alignment isn't the same as the new Graticule's, * because that might require a trip back to the internet, and by this * point, we should know that we don't need to do so. * * Also note that you can't do any cloning actions on a globalhash, since * that doesn't make any sense. * * @param i old Info object to clone * @param g new Graticule to apply * @throws InvalidParameterException the Info and Graticule do not lie on * the same side of the 30W line, or one * of the Graticules in question * represents a globalhash. * @return a new, improved Info object */ protected static Info cloneInfo(Info i, Graticule g) { if(i.isGlobalHash() || g == null) throw new InvalidParameterException("You can't clone a globalhash point, since that doesn't make any sense."); Graticule source = i.getGraticule(); if(source == null) throw new InvalidParameterException("You can't clone a globalhash point, since that doesn't make any sense."); // This sort of requires the 30W-itude of both to match. if(source.uses30WRule() != g.uses30WRule()) throw new InvalidParameterException("The given Info and Graticule do not lie on the same side of the 30W line; this should not have happened."); // Get the destination set... double lat = (g.getLatitude() + i.getLatitudeHash()) * (g.isSouth() ? -1 : 1); double lon = (g.getLongitude() + i.getLongitudeHash()) * (g.isWest() ? -1 : 1); // Then... return new Info(lat, lon, g, i.getCalendar()); } /** * Generate the hash string from the date and stock price. The REAL date, * that is. Not a 30W Rule-adjusted date. * * @param c date to use * @param stockPrice stock price to use * @return the hash you're looking for */ protected static String makeHash(Calendar c, String stockPrice) { // Just reset the hash. This can be handy alone if the graticule has // changed. Remember, c is the REAL date, not the STOCK date! String monthStr; String dayStr; // Zero-pad the month and date... if (c.get(Calendar.MONTH) + 1 < 10) monthStr = "0" + (c.get(Calendar.MONTH) + 1); else monthStr = Integer.valueOf(c.get(Calendar.MONTH) + 1).toString(); if (c.get(Calendar.DAY_OF_MONTH) < 10) dayStr = "0" + c.get(Calendar.DAY_OF_MONTH); else dayStr = Integer.valueOf(c.get(Calendar.DAY_OF_MONTH)).toString(); // And here it goes! String fullLine = c.get(Calendar.YEAR) + "-" + monthStr + "-" + dayStr + "-" + stockPrice; return MD5Tools.MD5hash(fullLine); } private static Info getQuickCache(Calendar sCal, Graticule g) { // We don't use Calendar.equals here, as that checks all properties, // including potentially some we don't really care about. boolean is30W = (g == null || g.uses30WRule()); // At any rate, first off, the most recent date/30W combo. Then, the // second-most. Failing THAT, return null. Log.v(DEBUG_TAG, "Checking quickcache for data..."); if(mLastInfo != null) { Calendar stored = mLastInfo.getCalendar(); if(stored.get(Calendar.MONTH) == sCal.get(Calendar.MONTH) && stored.get(Calendar.DAY_OF_MONTH) == sCal.get(Calendar.DAY_OF_MONTH) && stored.get(Calendar.YEAR) == sCal.get(Calendar.YEAR) && ((mLastInfo.getGraticule() == null && g == null) || (mLastInfo.getGraticule() != null && g != null)) && mLastInfo.uses30WRule() == is30W) { Log.v(DEBUG_TAG, "Hash data is in quick cache (mLastInfo): " + mLastInfo.getLatitudeHash() + ", " + mLastInfo.getLongitudeHash()); return mLastInfo; } } if(mTwoInfosAgo != null) { Calendar stored = mTwoInfosAgo.getCalendar(); if(stored.get(Calendar.MONTH) == sCal.get(Calendar.MONTH) && stored.get(Calendar.DAY_OF_MONTH) == sCal.get(Calendar.DAY_OF_MONTH) && stored.get(Calendar.YEAR) == sCal.get(Calendar.YEAR) && ((mTwoInfosAgo.getGraticule() == null && g == null) || (mTwoInfosAgo.getGraticule() != null && g != null)) && mTwoInfosAgo.uses30WRule() == is30W) { Log.v(DEBUG_TAG, "Hash data is in quick cache (mTwoInfosAgo): " + mTwoInfosAgo.getLatitudeHash() + ", " + mTwoInfosAgo.getLongitudeHash()); return mTwoInfosAgo; } } Log.v(DEBUG_TAG, "Data wasn't in quickcache."); return null; } /** * Gets the latitude value of the location for the current date. This is * attached to the current graticule integer value to produce the longitude. * * @return the fractional latitude value */ private static double getLatitudeHash(String hash) { String chunk = hash.substring(0, 16); return HexFraction.calculate(chunk); } /** * Gets the longitude value of the location for the current date. This is * attached to the current graticule integer value to produce the latitude. * * @return the fractional longitude value */ private static double getLongitudeHash(String hash) { String chunk = hash.substring(16, 32); return HexFraction.calculate(chunk); } private static double getLatitude(Graticule g, String hash) { // If the Graticule's not null, this is a normal hash. If it is, it's a // globalhash, and has to be treated differently. if(g != null) { int lat = g.getLatitude(); if (g.isSouth()) { return (lat + getLatitudeHash(hash)) * -1; } else { return lat + getLatitudeHash(hash); } } else { return getLatitudeHash(hash); } } private static double getLongitude(Graticule g, String hash) { // Same deal as with getLatitude. if(g != null) { int lon = g.getLongitude(); if (g.isWest()) { return (lon + getLongitudeHash(hash)) * -1; } else { return lon + getLongitudeHash(hash); } } else { return getLongitudeHash(hash); } } }