/* * Copyright (C) 2009 - 2013 Niall 'Rivernile' Scott * * This software is provided 'as-is', without any express or implied * warranty. In no event will the authors or contributors be held liable for * any damages arising from the use of this software. * * The aforementioned copyright holder(s) hereby grant you a * non-transferrable right to use this software for any purpose (including * commercial applications), and to modify it and redistribute it, subject to * the following conditions: * * 1. This notice may not be removed or altered from any file it appears in. * * 2. Any modifications made to this software, except those defined in * clause 3 of this agreement, must be released under this license, and * the source code of any modifications must be made available on a * publically accessible (and locateable) website, or sent to the * original author of this software. * * 3. Software modifications that do not alter the functionality of the * software but are simply adaptations to a specific environment are * exempt from clause 2. */ package uk.org.rivernile.edinburghbustracker.android; import android.app.backup.BackupManager; import static uk.org.rivernile.edinburghbustracker.android.PreferencesActivity .PREF_DATABASE_AUTO_UPDATE; import android.content.Context; import android.content.SharedPreferences; import android.content.SharedPreferences.OnSharedPreferenceChangeListener; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteException; import android.os.Build; import android.os.Looper; import android.widget.Toast; import com.bugsense.trace.BugSenseHandler; import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Random; import org.json.JSONException; import org.json.JSONObject; /** * This code is the very first code that will be executed when the application * is started. It is used to register the BugSense handler, put a listener on * the SharedPreferences for Google Backup on Froyo upwards, and check for bus * stop database updates. * * The Android developer documentation discourages the usage of this class, but * as it is unpredictable where the user will enter the application the code is * put here as this class is always instantiated when this application's process * is created. * * @author Niall Scott */ public class Application extends android.app.Application { private static final String DB_API_CHECK_URL = "http://www.mybustracker.co.uk/ws.php?module=json&function=" + "getTopoId&key="; private static final String DB_UPDATE_CHECK_URL = "http://edinb.us/api/DatabaseVersion?schemaType=" + BusStopDatabase.SCHEMA_NAME + "&random="; private static final Random random = new Random(System.currentTimeMillis()); /** * {@inheritDoc} */ @Override public void onCreate() { super.onCreate(); // Register the BugSense handler. BugSenseHandler.initAndStartSession(this, ApiKey.BUGSENSE_KEY); // Cause the bus stop database to be extracted straight away. BusStopDatabase.getInstance(this); // If the API level is Froyo or greater, then register the // SharedPreference listener. if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO) getSharedPreferences(PreferencesActivity.PREF_FILE, 0) .registerOnSharedPreferenceChangeListener( new SharedPreferencesListener(this)); // Start the thread to check for bus stop database updates. new Thread(stopDBTasks).start(); } private Runnable stopDBTasks = new Runnable() { @Override public void run() { // Delete old database files if they exist. File toDelete = getDatabasePath("busstops.db"); if(toDelete.exists()) toDelete.delete(); toDelete = getDatabasePath("busstops.db-journal"); if(toDelete.exists()) toDelete.delete(); toDelete = getDatabasePath("busstops2.db"); if(toDelete.exists()) toDelete.delete(); toDelete = getDatabasePath("busstops2.db-journal"); if(toDelete.exists()) toDelete.delete(); toDelete = getDatabasePath("busstops8.db"); if(toDelete.exists()) toDelete.delete(); toDelete = getDatabasePath("busstops8.db-journal"); if(toDelete.exists()) toDelete.delete(); // Start update task. checkForDBUpdates(getApplicationContext(), false); } }; /** * Check for updates to the bus stop database. This may happen automatically * if 24 hours have elapsed since the last check, or if the user has forced * the action. If a database update is found, then the new database is * downloaded and placed in the correct location. * * @param context The context. * @param force True if the user forced the check, false if not. */ public static void checkForDBUpdates(final Context context, final boolean force) { // Check to see if the user wants their database automatically updated. final SharedPreferences sp = context.getSharedPreferences( PreferencesActivity.PREF_FILE, 0); final boolean autoUpdate = sp.getBoolean(PREF_DATABASE_AUTO_UPDATE, true); final SharedPreferences.Editor edit = sp.edit(); // Continue to check if the user has enabled it, or a check has been // forced (from the Preferences). if(autoUpdate || force) { if(!force) { // If it has not been forced, check the last update time. It is // only checked once per day. Abort if it is too soon. long lastCheck = sp.getLong("lastUpdateCheck", 0); if((System.currentTimeMillis() - lastCheck) < 86400000) return; } // Construct the checking URL. final StringBuilder sb = new StringBuilder(); sb.append(DB_API_CHECK_URL); sb.append(ApiKey.getHashedKey()); sb.append("&random="); // A random number is used so networks don't cache the HTTP // response. sb.append(random.nextInt()); try { // Do connection stuff. final URL url = new URL(sb.toString()); sb.setLength(0); final HttpURLConnection conn = (HttpURLConnection)url .openConnection(); try { final BufferedInputStream is = new BufferedInputStream( conn.getInputStream()); if(!url.getHost().equals(conn.getURL().getHost())) { is.close(); conn.disconnect(); return; } // Read the incoming data. int data; while((data = is.read()) != -1) { sb.append((char)data); } } finally { // Whether there's an error or not, disconnect. conn.disconnect(); } } catch(MalformedURLException e) { return; } catch(IOException e) { return; } String topoId; try { // Parse the JSON and get the topoId from it. final JSONObject jo = new JSONObject(sb.toString()); topoId = jo.getString("topoId"); } catch(JSONException e) { return; } // If there's topoId then it cannot continue. if(topoId == null || topoId.length() == 0) return; // Get the current topoId from the database. final BusStopDatabase bsd = BusStopDatabase .getInstance(context.getApplicationContext()); final String dbTopoId = bsd.getTopoId(); // If the topoIds match, write our check time to SharedPreferences. if(topoId.equals(dbTopoId)) { edit.putLong("lastUpdateCheck", System.currentTimeMillis()); edit.commit(); if(force) { // It was forced, alert the user there is no update // available. Looper.prepare(); Toast.makeText(context, R.string.bus_stop_db_no_updates, Toast.LENGTH_LONG).show(); Looper.loop(); } return; } // There is an update available. Empty the StringBuilder then create // the URL to get the new database information. sb.setLength(0); sb.append(DB_UPDATE_CHECK_URL); sb.append(random.nextInt()); sb.append("&key="); sb.append(ApiKey.getHashedKey()); try { // Connection stuff. final URL url = new URL(sb.toString()); sb.setLength(0); final HttpURLConnection conn = (HttpURLConnection)url .openConnection(); try { final BufferedInputStream is = new BufferedInputStream( conn.getInputStream()); if(!url.getHost().equals(conn.getURL().getHost())) { is.close(); conn.disconnect(); return; } int data; // Read the incoming data. while((data = is.read()) != -1) { sb.append((char)data); } } finally { // Whether there's an error or not, disconnect. conn.disconnect(); } } catch(MalformedURLException e) { return; } catch(IOException e) { return; } String dbUrl, schemaVersion, checksum; try { // Get the data from tje returned JSON. final JSONObject jo = new JSONObject(sb.toString()); dbUrl = jo.getString("db_url"); schemaVersion = jo.getString("db_schema_version"); topoId = jo.getString("topo_id"); checksum = jo.getString("checksum"); } catch(JSONException e) { // There was an error parsing the JSON, it cannot continue. return; } // Make sure the returned schema name is compatible with the one // the app uses. if(!BusStopDatabase.SCHEMA_NAME.equals(schemaVersion)) return; // Some basic sanity checking on the parameters. if(topoId == null || topoId.length() == 0) return; if(dbUrl == null || dbUrl.length() == 0) return; if(checksum == null || checksum.length() == 0) return; // Make sure an update really is available. if(!topoId.equals(dbTopoId)) { // Update the database. updateStopsDB(context, dbUrl, checksum); } else if(force) { // Tell the user there is no update available. Looper.prepare(); Toast.makeText(context, R.string.bus_stop_db_no_updates, Toast.LENGTH_LONG).show(); Looper.loop(); } // Write to the SharedPreferences the last update time. edit.putLong("lastUpdateCheck", System.currentTimeMillis()); edit.commit(); } } /** * Download the stop database from the server and put it in the * application's working data directory. * * @param context The context to use this method with. * @param url The URL of the bus stop database to download. */ private static void updateStopsDB(final Context context, final String url, final String checksum) { if(context == null || url == null || url.length() == 0 || checksum == null || checksum.length() == 0) return; try { // Connect to the server. final URL u = new URL(url); final HttpURLConnection con = (HttpURLConnection)u.openConnection(); final InputStream in = con.getInputStream(); // Make sure the URL is what we expect. if(!u.getHost().equals(con.getURL().getHost())) { in.close(); con.disconnect(); return; } // The location the file should be downloaded to. final File temp = context .getDatabasePath(BusStopDatabase.STOP_DB_NAME + "_temp"); // The eventual destination of the file. final File dest = context .getDatabasePath(BusStopDatabase.STOP_DB_NAME); final FileOutputStream out = new FileOutputStream(temp); // Get the file from the server. byte[] buf = new byte[1024]; int len; while((len = in.read(buf)) > 0) { out.write(buf, 0, len); } // Make sure the stream is flushed then close resources and // disconnect. out.flush(); out.close(); in.close(); con.disconnect(); // Do a MD5 checksum on the downloaded file. Make sure it matches // what the server reported. if(!md5Checksum(temp).equalsIgnoreCase(checksum)) { // If it doesn't match, delete the downloaded file. temp.delete(); return; } try { // Open the temp database and execute the index operation on it. final SQLiteDatabase db = SQLiteDatabase.openDatabase( temp.getAbsolutePath(), null, SQLiteDatabase.OPEN_READWRITE); BusStopDatabase.setUpIndexes(db); db.close(); } catch(SQLiteException e) { // If we couldn't create the index, continue anyway. The user // will still be able to use the database, it will just run // slowly if they want route lines. } // Close a currently open database. Delete the old database then // move the downloaded file in to its place. Do this while // synchronized to make sure noting else uses the database in this // time. final BusStopDatabase bsd = BusStopDatabase .getInstance(context.getApplicationContext()); synchronized(bsd) { try { bsd.getReadableDatabase().close(); } catch (SQLiteException e) { // Nothing to do here. Assume it's already closed. } dest.delete(); temp.renameTo(dest); } // Delete the associated journal file because we no longer need it. final File journalFile = context .getDatabasePath(BusStopDatabase.STOP_DB_NAME + "_temp-journal"); if(journalFile.exists()) journalFile.delete(); // Alert the user that the database has been updated. Looper.prepare(); Toast.makeText(context, R.string.bus_stop_db_updated, Toast.LENGTH_LONG).show(); Looper.loop(); } catch(MalformedURLException e) { } catch(IOException e) { } } /** * Create a checksum for a File. This is used to ensure that a downloaded * database has not been corrupted or incomplete. * * See: http://vyshemirsky.blogspot.com/2007/08/computing-md5-digest-checksum-in-java.html * This has been slightly modified. * * @param file The file to run the MD5 checksum against. * @return The MD5 checksum string. */ public static String md5Checksum(final File file) { try { final InputStream fin = new FileInputStream(file); final MessageDigest md5er = MessageDigest.getInstance("MD5"); final byte[] buffer = new byte[1024]; int read; while((read = fin.read(buffer)) != -1) { if(read > 0) md5er.update(buffer, 0, read); } fin.close(); final byte[] digest = md5er.digest(); if(digest == null) return null; final StringBuilder builder = new StringBuilder(); for(byte a : digest) { builder.append(Integer.toString((a & 0xff) + 0x100, 16).substring(1)); } return builder.toString(); } catch(FileNotFoundException e) { return ""; } catch(NoSuchAlgorithmException e) { return ""; } catch(IOException e) { return ""; } } /** * The SharedPreferencesListener will look out for changes to the shared * preferences and schedule updates with Google Backup if there is, if the * device is running Android 2.2 (Froyo) or greater. */ public static class SharedPreferencesListener implements OnSharedPreferenceChangeListener { final Context context; /** * Constructor, supplying a Context instance. * * @param context The application Context. */ public SharedPreferencesListener(final Context context) { this.context = context; } /** * {@inheritDoc} */ @Override public void onSharedPreferenceChanged(final SharedPreferences sp, final String key) { BackupManager.dataChanged(context.getPackageName()); } } }